mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
resolve merge conflict
This commit is contained in:
7
.github/workflows/deploy.yml
vendored
7
.github/workflows/deploy.yml
vendored
@@ -144,15 +144,16 @@ jobs:
|
|||||||
run: ./scripts/deploy/create-npmrc.sh
|
run: ./scripts/deploy/create-npmrc.sh
|
||||||
|
|
||||||
- name: Resolve env file path for ${{ matrix.service }}
|
- 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: |
|
run: |
|
||||||
case "${{ matrix.service }}" in
|
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" ;;
|
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" ;;
|
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Build migration image for ${{ matrix.service }}
|
- 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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker build \
|
docker build \
|
||||||
@@ -163,7 +164,7 @@ jobs:
|
|||||||
.
|
.
|
||||||
|
|
||||||
- name: Run migrations for ${{ matrix.service }}
|
- 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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"
|
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"
|
||||||
|
|||||||
@@ -154,13 +154,32 @@ no timeout and will wait forever.
|
|||||||
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
|
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
|
||||||
already come from it.
|
already come from it.
|
||||||
|
|
||||||
- `migrationsRun: true` — **migrations run automatically on API boot**, with
|
- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
|
||||||
`migrationsTransactionMode: 'each'`.
|
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:
|
- 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
|
- 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.
|
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
|
- **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
|
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.
|
adding one, check the filename prefix is unused *and* higher than the newest recorded row.
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ COPY --from=builder /app/ .
|
|||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
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
|
FROM base AS runner
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
"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",
|
"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",
|
"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: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: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",
|
"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:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
"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"
|
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -57,8 +59,8 @@
|
|||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"@nestjs/websockets": "^11.1.27",
|
"@nestjs/websockets": "^11.1.27",
|
||||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.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-0.7.12.tgz",
|
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
||||||
"amqp-connection-manager": "^5.0.0",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
"amqplib": "^2.0.1",
|
"amqplib": "^2.0.1",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { registerAs } from "@nestjs/config";
|
import { registerAs } from "@nestjs/config";
|
||||||
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
|
import { DataSourceOptions } from "typeorm";
|
||||||
import { join, dirname } from "path";
|
import { join, dirname } from "path";
|
||||||
import {
|
import {
|
||||||
DefaultPosition,
|
DefaultPosition,
|
||||||
@@ -44,8 +45,30 @@ import {
|
|||||||
NotificationTemplate,
|
NotificationTemplate,
|
||||||
} from "@tria-plc/iamapi-common";
|
} from "@tria-plc/iamapi-common";
|
||||||
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
|
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 = [
|
const iamEntities = [
|
||||||
|
UnitSetting,
|
||||||
|
UnitDetail,
|
||||||
|
OrganizationDetail,
|
||||||
|
UnitCluster,
|
||||||
|
Location,
|
||||||
|
LocationType,
|
||||||
|
DelegationTerminationReason,
|
||||||
|
EmployeePositionActivePeriod,
|
||||||
|
UnitConfiguration,
|
||||||
|
Site,
|
||||||
|
SiteSetting,
|
||||||
DefaultPosition,
|
DefaultPosition,
|
||||||
DefaultUnit,
|
DefaultUnit,
|
||||||
EmployeePosition,
|
EmployeePosition,
|
||||||
@@ -95,7 +118,7 @@ const iamMigrationsGlob = join(
|
|||||||
);
|
);
|
||||||
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
||||||
|
|
||||||
export default registerAs("database", (): TypeOrmModuleOptions => {
|
export function buildDataSourceOptions(): DataSourceOptions {
|
||||||
return {
|
return {
|
||||||
type: "postgres",
|
type: "postgres",
|
||||||
host: process.env.DB_HOST ?? "localhost",
|
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`
|
// The search_path is instead applied per-connection via a pool `connect`
|
||||||
// handler in app.module.ts (see setPoolSearchPath).
|
// handler in app.module.ts (see setPoolSearchPath).
|
||||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||||
autoLoadEntities: true,
|
|
||||||
migrations: [
|
migrations: [
|
||||||
// IAM schema + tables must be created before freight migrations
|
|
||||||
iamMigrationsGlob,
|
iamMigrationsGlob,
|
||||||
freightMigrationsGlob,
|
freightMigrationsGlob,
|
||||||
],
|
],
|
||||||
migrationsRun: true,
|
|
||||||
migrationsTransactionMode: "each",
|
migrationsTransactionMode: "each",
|
||||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging:
|
logging:
|
||||||
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
|
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export default registerAs("database", (): TypeOrmModuleOptions => ({
|
||||||
|
...buildDataSourceOptions(),
|
||||||
|
autoLoadEntities: true,
|
||||||
|
migrationsRun: false,
|
||||||
|
}));
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
// apps/edr-freight-api/src/data-source.ts
|
// apps/edr-freight-api/src/data-source.ts
|
||||||
import 'dotenv/config';
|
import "dotenv/config";
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from "typeorm";
|
||||||
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
import { buildDataSourceOptions } from "./config/database.config";
|
||||||
|
|
||||||
export const AppDataSource = new DataSource({
|
export const AppDataSource = new DataSource(buildDataSourceOptions());
|
||||||
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',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optional: call ensurePostgresSchemas before initializing
|
export default AppDataSource;
|
||||||
// But you can also run it separately.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
|
|||||||
expect(manager.update).not.toHaveBeenCalled();
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
|||||||
|
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||||
import {
|
import {
|
||||||
InvoiceDocumentModel,
|
InvoiceDocumentModel,
|
||||||
InvoiceDocumentService,
|
InvoiceDocumentService,
|
||||||
@@ -55,6 +55,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
|||||||
Freight.InvoiceStatus.Overdue,
|
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. */
|
/** A single line to bill on a generated invoice. */
|
||||||
export interface InvoiceLineInput {
|
export interface InvoiceLineInput {
|
||||||
chargeType: string;
|
chargeType: string;
|
||||||
@@ -352,6 +373,34 @@ export class BillingService {
|
|||||||
return this.payInvoice(id, opts);
|
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). */
|
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||||
async documentForUser(
|
async documentForUser(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -1006,6 +1055,7 @@ export class BillingService {
|
|||||||
): Promise<InitiateResponseDto> {
|
): Promise<InitiateResponseDto> {
|
||||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||||
|
relations: { company: true },
|
||||||
});
|
});
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
@@ -1035,6 +1085,17 @@ export class BillingService {
|
|||||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
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({
|
const result = await this.payment.initiate({
|
||||||
referenceId: invoice.sourceId,
|
referenceId: invoice.sourceId,
|
||||||
source: invoice.source,
|
source: invoice.source,
|
||||||
@@ -1051,6 +1112,10 @@ export class BillingService {
|
|||||||
method: opts.method ?? "TELEBIRR",
|
method: opts.method ?? "TELEBIRR",
|
||||||
platform: opts.platform,
|
platform: opts.platform,
|
||||||
payerAccount: opts.payerAccount,
|
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,
|
returnUrl: opts.returnUrl,
|
||||||
failureUrl: opts.failureUrl,
|
failureUrl: opts.failureUrl,
|
||||||
});
|
});
|
||||||
@@ -1062,7 +1127,13 @@ export class BillingService {
|
|||||||
|
|
||||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||||
// billing must not simulate it. Kept commented for local demos only.
|
// 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({
|
await this.payment.handlePaymentEvent({
|
||||||
eventType: "payment.succeeded",
|
eventType: "payment.succeeded",
|
||||||
eventId: `demo-${result.intentId}`,
|
eventId: `demo-${result.intentId}`,
|
||||||
@@ -1107,4 +1178,67 @@ export class BillingService {
|
|||||||
paidAt,
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
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. */
|
/** Gateway options for paying an invoice from the customer portal. */
|
||||||
export class PayInvoiceDto {
|
export class PayInvoiceDto {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "../../common/resolve-auth-user-id";
|
} from "../../common/resolve-auth-user-id";
|
||||||
import { sendPdf } from "./billing.controller";
|
import { sendPdf } from "./billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
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,
|
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||||
@@ -96,4 +96,20 @@ export class PortalBillingController {
|
|||||||
failureUrl: dto.failureUrl,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,6 +464,26 @@ export class BookingsController {
|
|||||||
res.send(buffer);
|
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')
|
@Get(':id/customer-trucks')
|
||||||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||||
async listCustomerTrucks(
|
async listCustomerTrucks(
|
||||||
|
|||||||
@@ -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 URGENT_PRIORITY_THRESHOLD = 1000;
|
||||||
const NEEDS_ACTION_STATUSES = [
|
const NEEDS_ACTION_STATUSES = [
|
||||||
'SUBMITTED',
|
'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. */
|
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||||
/**
|
/**
|
||||||
* An intercity corridor is valid when both yards are Ethiopian and at least
|
* An intercity corridor is valid when both yards are Ethiopian and at least
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat
|
|||||||
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||||
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
||||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.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 { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||||
|
|
||||||
@@ -685,6 +686,17 @@ export class CompaniesController {
|
|||||||
return requests.map((r) => new ChangeRequestResponseDto(r));
|
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")
|
@Post("change-requests/:id/approve")
|
||||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -719,6 +731,25 @@ export class CompaniesController {
|
|||||||
return new ChangeRequestResponseDto(request);
|
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")
|
@Post(":companyId/profiles")
|
||||||
@BookingStaff(FREIGHT_PERMS.customers.update)
|
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||||
|
|||||||
@@ -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
|
* come from the verified payload, not typed. Fayda's userinfo carries no
|
||||||
* national ID number, so none is collected or derived here.
|
* national ID number, so none is collected or derived here.
|
||||||
*
|
*
|
||||||
* - Ethiopian company: the owner (and its PoA, once named) is verified through
|
* Only the OWNER's credential varies by nationality:
|
||||||
* Fayda, and their details can't be edited afterwards.
|
* - Ethiopian company: the owner is verified through Fayda.
|
||||||
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
* - 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
|
* supplies a typed passport number — required on its own, whether or not the
|
||||||
* owner also completes a (purely optional) Fayda verification.
|
* 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 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
|
* the portal offers a "same as owner" copy for, but it is never itself
|
||||||
* Fayda-verified or gated on.
|
* Fayda-verified or gated on.
|
||||||
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
},
|
},
|
||||||
changeRequestRepo: {
|
changeRequestRepo: {
|
||||||
findPendingByCompanyId: jest.fn(async () => null),
|
findPendingByCompanyId: jest.fn(async () => null),
|
||||||
|
findLatestOpenByCompanyId: jest.fn(async () => null),
|
||||||
findByCompanyId: jest.fn(async () => []),
|
findByCompanyId: jest.fn(async () => []),
|
||||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
id: "cr-1",
|
id: "cr-1",
|
||||||
@@ -114,6 +120,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
})),
|
})),
|
||||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
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: {
|
profilesRepo: {
|
||||||
findByCompanyId: jest.fn(async () => []),
|
findByCompanyId: jest.fn(async () => []),
|
||||||
findByUserId: jest.fn(async () => ({
|
findByUserId: jest.fn(async () => ({
|
||||||
@@ -138,6 +151,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
deps.companiesRepo as never,
|
deps.companiesRepo as never,
|
||||||
deps.companyProfilesRepo as never,
|
deps.companyProfilesRepo as never,
|
||||||
deps.changeRequestRepo as never,
|
deps.changeRequestRepo as never,
|
||||||
|
deps.revisionRepo as never,
|
||||||
deps.profilesRepo as never,
|
deps.profilesRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
deps.filesService as never,
|
deps.filesService as never,
|
||||||
@@ -229,9 +243,27 @@ describe("Fayda identity verification binds a person to the company", () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stages the change for review on an approved company", async () => {
|
it("stages an owner re-verification for review on an approved company", async () => {
|
||||||
// Swapping the person who can act for a live company is exactly what the
|
// The owner is the live company's identity proof, so re-verifying one is
|
||||||
// backoffice review exists for, so it must not rewrite the row directly.
|
// 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({
|
const { service, ctx, deps } = makeService({
|
||||||
status: CompanyStatus.Active,
|
status: CompanyStatus.Active,
|
||||||
});
|
});
|
||||||
@@ -242,8 +274,8 @@ describe("Fayda identity verification binds a person to the company", () => {
|
|||||||
state: "s",
|
state: "s",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
||||||
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
|
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses to rename a verified person by hand", async () => {
|
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);
|
).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({
|
const { service } = makeService({
|
||||||
profileTypes: [ProfileType.importer],
|
profileTypes: [ProfileType.importer],
|
||||||
nationality: CompanyNationality.Foreign,
|
nationality: CompanyNationality.Foreign,
|
||||||
@@ -385,7 +439,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
"user-1",
|
"user-1",
|
||||||
ProfileType.freightForwarder,
|
ProfileType.freightForwarder,
|
||||||
),
|
),
|
||||||
).resolves.toBeDefined();
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { Company } from "./entities/company.entity";
|
|||||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||||
|
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
|
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
import { CompanyNotifierService } from "./company-notifier.service";
|
import { CompanyNotifierService } from "./company-notifier.service";
|
||||||
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||||
@@ -29,6 +31,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
|||||||
ExternalProfile,
|
ExternalProfile,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
|
CompanyRevision,
|
||||||
Booking,
|
Booking,
|
||||||
]),
|
]),
|
||||||
HttpModule,
|
HttpModule,
|
||||||
@@ -49,6 +52,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
|||||||
ExternalProfileRepository,
|
ExternalProfileRepository,
|
||||||
CompanyProfileRepository,
|
CompanyProfileRepository,
|
||||||
CompanyChangeRequestRepository,
|
CompanyChangeRequestRepository,
|
||||||
|
CompanyRevisionRepository,
|
||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
ETradeService,
|
ETradeService,
|
||||||
CompanyNotifierService,
|
CompanyNotifierService,
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
findPendingByCompanyId: jest.fn(async () =>
|
findPendingByCompanyId: jest.fn(async () =>
|
||||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
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 () => []),
|
findByCompanyId: jest.fn(async () => []),
|
||||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
id: "cr-1",
|
id: "cr-1",
|
||||||
@@ -88,6 +91,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
})),
|
})),
|
||||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
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: {
|
profilesRepo: {
|
||||||
findByCompanyId: jest.fn(async () => []),
|
findByCompanyId: jest.fn(async () => []),
|
||||||
findByUserId: jest.fn(async () => ({
|
findByUserId: jest.fn(async () => ({
|
||||||
@@ -118,6 +128,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
deps.companiesRepo as never,
|
deps.companiesRepo as never,
|
||||||
deps.companyProfilesRepo as never,
|
deps.companyProfilesRepo as never,
|
||||||
deps.changeRequestRepo as never,
|
deps.changeRequestRepo as never,
|
||||||
|
deps.revisionRepo as never,
|
||||||
deps.profilesRepo as never,
|
deps.profilesRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
deps.filesService as never,
|
deps.filesService as never,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function makeService(existing: ExistingProfile[]) {
|
|||||||
companiesRepo as never,
|
companiesRepo as never,
|
||||||
companyProfilesRepo as never,
|
companyProfilesRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
profilesRepo as never,
|
profilesRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -9,6 +10,11 @@ import { DataSource, EntityManager } from "typeorm";
|
|||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.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 { ExternalProfileRepository } from "./external-profile.repository";
|
||||||
import {
|
import {
|
||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
@@ -64,6 +70,10 @@ import {
|
|||||||
DocumentChangeIntent,
|
DocumentChangeIntent,
|
||||||
LicenseChangeIntent,
|
LicenseChangeIntent,
|
||||||
} from "./entities/company-change-request.entity";
|
} from "./entities/company-change-request.entity";
|
||||||
|
import {
|
||||||
|
CompanyRevision,
|
||||||
|
CompanyRevisionChange,
|
||||||
|
} from "./entities/company-revision.entity";
|
||||||
|
|
||||||
/** FileRecord `resource` + `code` slots for business-license documents. */
|
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||||
const LICENSE_RESOURCE = "company_profiles";
|
const LICENSE_RESOURCE = "company_profiles";
|
||||||
@@ -81,6 +91,28 @@ const POA_ATTRIBUTES = [
|
|||||||
"poaLocation",
|
"poaLocation",
|
||||||
"poaAddress",
|
"poaAddress",
|
||||||
] as const;
|
] 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. */
|
/** Mandatory once the company operates as a freight forwarder. */
|
||||||
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||||
{ key: "poaName", label: "PoA name" },
|
{ key: "poaName", label: "PoA name" },
|
||||||
@@ -154,10 +186,13 @@ export interface UserIdentity {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CompaniesService {
|
export class CompaniesService {
|
||||||
|
private readonly logger = new Logger(CompaniesService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly companiesRepo: CompaniesRepository,
|
private readonly companiesRepo: CompaniesRepository,
|
||||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||||
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
||||||
|
private readonly revisionRepo: CompanyRevisionRepository,
|
||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
@@ -660,7 +695,16 @@ export class CompaniesService {
|
|||||||
|
|
||||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||||
const before = await this.findCompanyById(id);
|
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`);
|
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||||
|
|
||||||
// Suspending or blacklisting locks the customer out, so they must be told.
|
// Suspending or blacklisting locks the customer out, so they must be told.
|
||||||
@@ -818,6 +862,34 @@ export class CompaniesService {
|
|||||||
return companyUpdates;
|
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. */
|
/** Reject a TIN already registered to a *different* company. */
|
||||||
private async assertTinAvailable(
|
private async assertTinAvailable(
|
||||||
company: Company,
|
company: Company,
|
||||||
@@ -844,10 +916,11 @@ export class CompaniesService {
|
|||||||
*
|
*
|
||||||
* - Company not yet approved (onboarding) → write straight to the Company row,
|
* - Company not yet approved (onboarding) → write straight to the Company row,
|
||||||
* as before. The company/role pending→approve gate already covers first-run.
|
* 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
|
* - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
|
||||||
* a pending change request (merging into any open one) so a backoffice
|
* still write straight through; everything else does NOT touch the live
|
||||||
* reviewer can approve (apply) or reject (with a note). This locks the
|
* Company but is staged in a pending change request (merging into any open
|
||||||
* customer until the review resolves.
|
* 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(
|
async updateProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -879,12 +952,41 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company ${company.id} not found`);
|
throw new NotFoundException(`Company ${company.id} not found`);
|
||||||
|
await this.recordCompanyRevision(company, companyUpdates, userId);
|
||||||
return new ProfileResponseDto(profile, updated);
|
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);
|
await this.assertTinAvailable(company, dto.tin);
|
||||||
const fields = this.pickDefined(dto);
|
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(
|
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||||
company.id,
|
company.id,
|
||||||
@@ -894,10 +996,14 @@ export class CompaniesService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
request =
|
request =
|
||||||
(await this.changeRequestRepo.update(existing.id, {
|
(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,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
status: ChangeRequestStatus.Pending,
|
||||||
})) ?? existing;
|
})) ?? existing;
|
||||||
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
||||||
} else {
|
} else {
|
||||||
@@ -910,7 +1016,7 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
request = await this.changeRequestRepo.create({
|
request = await this.changeRequestRepo.create({
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
snapshot: fields,
|
snapshot: staged,
|
||||||
status: ChangeRequestStatus.Pending,
|
status: ChangeRequestStatus.Pending,
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
@@ -922,8 +1028,9 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live company is unchanged; surface the pending state for the settings page.
|
// Only the personnel half (if any) landed; surface the pending state for
|
||||||
return new ProfileResponseDto(profile, company, request);
|
// the settings page.
|
||||||
|
return new ProfileResponseDto(profile, live, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List a company's change requests, newest first (backoffice review). */
|
/** List a company's change requests, newest first (backoffice review). */
|
||||||
@@ -934,6 +1041,53 @@ export class CompaniesService {
|
|||||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
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
|
* 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
|
* 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.applyLicenseChanges(request);
|
||||||
await this.applyDocumentChanges(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 (
|
return (
|
||||||
(await this.changeRequestRepo.update(id, {
|
(await this.changeRequestRepo.update(id, {
|
||||||
status: ChangeRequestStatus.Approved,
|
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
|
* Upload company documents. For an approved company this also opens/updates a
|
||||||
* pending change request (recording the uploaded file ids) so the upload is
|
* pending change request (recording the uploaded file ids) so the upload is
|
||||||
* reviewed and the customer is locked until it clears — consistent with the
|
* 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
|
* 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(
|
async uploadCompanyDocuments(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -986,11 +1214,20 @@ export class CompaniesService {
|
|||||||
submittedBy?: string,
|
submittedBy?: string,
|
||||||
): Promise<FileRecord[]> {
|
): Promise<FileRecord[]> {
|
||||||
const company = await this.findCompanyById(companyId);
|
const company = await this.findCompanyById(companyId);
|
||||||
|
const before = await this.filesService.findByResource(
|
||||||
|
companyId,
|
||||||
|
"companies",
|
||||||
|
);
|
||||||
const uploaded = await this.filesService.uploadMany(
|
const uploaded = await this.filesService.uploadMany(
|
||||||
companyId,
|
companyId,
|
||||||
"companies",
|
"companies",
|
||||||
files,
|
files,
|
||||||
);
|
);
|
||||||
|
const documentChanges = await this.replaceSingleFileCompanyDocuments(
|
||||||
|
company,
|
||||||
|
before,
|
||||||
|
uploaded,
|
||||||
|
);
|
||||||
await this.resolveDocumentChangeRequests(
|
await this.resolveDocumentChangeRequests(
|
||||||
companyId,
|
companyId,
|
||||||
"companies",
|
"companies",
|
||||||
@@ -1004,6 +1241,9 @@ export class CompaniesService {
|
|||||||
submittedBy,
|
submittedBy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (documentChanges.length > 0) {
|
||||||
|
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
||||||
|
}
|
||||||
return uploaded;
|
return uploaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,7 +1358,8 @@ export class CompaniesService {
|
|||||||
},
|
},
|
||||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
});
|
});
|
||||||
if (company) {
|
if (company) {
|
||||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
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> {
|
async deleteCompany(id: string): Promise<void> {
|
||||||
await this.findCompanyById(id);
|
await this.findCompanyById(id);
|
||||||
await this.companiesRepo.softDelete(id);
|
await this.companiesRepo.softDelete(id);
|
||||||
@@ -1420,6 +1692,7 @@ export class CompaniesService {
|
|||||||
) {
|
) {
|
||||||
await companyRepo.update(updated.companyId, {
|
await companyRepo.update(updated.companyId, {
|
||||||
status: CompanyStatus.Active,
|
status: CompanyStatus.Active,
|
||||||
|
approvedAt: new Date(),
|
||||||
});
|
});
|
||||||
this.companyNotifier.companyApproved(company);
|
this.companyNotifier.companyApproved(company);
|
||||||
}
|
}
|
||||||
@@ -1720,16 +1993,11 @@ export class CompaniesService {
|
|||||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
// An Ethiopian company does not type its PoA details at all — they arrive
|
// No company types its PoA details — they arrive from the Fayda
|
||||||
// from the Fayda verification — so reporting them as missing fields would
|
// verification whatever the nationality — so reporting them as missing
|
||||||
// ask for something the form no longer offers. The identity block below
|
// fields would ask for something no form offers. The identity block below
|
||||||
// reports "verify your PoA" instead.
|
// reports "verify your PoA" instead.
|
||||||
const missingPoaFields =
|
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
|
||||||
poaRequired && !identity.faydaRequired
|
|
||||||
? REQUIRED_POA_FIELDS.filter(
|
|
||||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const delegation = await this.getPoaDelegationState(company.id);
|
const delegation = await this.getPoaDelegationState(company.id);
|
||||||
const delegationDue = poaRequired || poaProvided;
|
const delegationDue = poaRequired || poaProvided;
|
||||||
const missingDelegation = delegationDue && !delegation.onFile;
|
const missingDelegation = delegationDue && !delegation.onFile;
|
||||||
@@ -1754,9 +2022,7 @@ export class CompaniesService {
|
|||||||
...(identity.faydaRequired && !identity.owner.verified
|
...(identity.faydaRequired && !identity.owner.verified
|
||||||
? ["Verify the company owner's identity with Fayda"]
|
? ["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"]
|
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||||
: []),
|
: []),
|
||||||
...(identity.passportRequired && !identity.owner.passportNumber
|
...(identity.passportRequired && !identity.owner.passportNumber
|
||||||
@@ -1768,26 +2034,20 @@ export class CompaniesService {
|
|||||||
// fields, required documents, one license per operational profile, and the
|
// fields, required documents, one license per operational profile, and the
|
||||||
// PoA details/paper whenever those are mandatory.
|
// PoA details/paper whenever those are mandatory.
|
||||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||||
const poaItemCount =
|
const poaItemCount = delegationDue ? 1 : 0;
|
||||||
(poaRequired && !identity.faydaRequired
|
|
||||||
? REQUIRED_POA_FIELDS.length
|
|
||||||
: 0) + (delegationDue ? 1 : 0);
|
|
||||||
// One item per identity credential the company has to prove: the owner
|
// One item per identity credential the company has to prove: the owner
|
||||||
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
|
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
||||||
// is one and Fayda is what's mandatory here.
|
// there is one — that one is Fayda whatever the nationality.
|
||||||
const identityItemCount = identity.faydaRequired
|
const ownerCredentialDue =
|
||||||
? delegationDue
|
identity.faydaRequired || identity.passportRequired;
|
||||||
? 2
|
const ownerCredentialProven = identity.faydaRequired
|
||||||
: 1
|
? identity.owner.verified
|
||||||
: identity.passportRequired
|
: Boolean(identity.owner.passportNumber);
|
||||||
? 1
|
const identityItemCount =
|
||||||
: 0;
|
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||||
const missingIdentityCount = identity.faydaRequired
|
const missingIdentityCount =
|
||||||
? (identity.owner.verified ? 0 : 1) +
|
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||||
(delegationDue && !identity.poa.verified ? 1 : 0)
|
(delegationDue && !identity.poa.verified ? 1 : 0);
|
||||||
: identity.passportRequired && !identity.owner.passportNumber
|
|
||||||
? 1
|
|
||||||
: 0;
|
|
||||||
const total =
|
const total =
|
||||||
requiredInfo.length +
|
requiredInfo.length +
|
||||||
requiredDocCount +
|
requiredDocCount +
|
||||||
@@ -2225,7 +2485,8 @@ export class CompaniesService {
|
|||||||
},
|
},
|
||||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.changeRequestRepo.create({
|
await this.changeRequestRepo.create({
|
||||||
@@ -2453,11 +2714,13 @@ export class CompaniesService {
|
|||||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// An approved company's profile edits are staged for backoffice review, and
|
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||||
// swapping the person who can act for the company is exactly the kind of
|
// is staged for backoffice review rather than quietly rewriting a live
|
||||||
// edit that review exists for — so a verification lands the same way an
|
// record. The PoA is personnel — the company names its own representative,
|
||||||
// ordinary edit does, rather than quietly rewriting a live record.
|
// and the delegation letter backing them is what the reviewer sees — so a
|
||||||
if (company.status === CompanyStatus.Active) {
|
// 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);
|
await this.stageIdentityChange(company, userId, identity);
|
||||||
return this.getCompanyIdentityState(company);
|
return this.getCompanyIdentityState(company);
|
||||||
}
|
}
|
||||||
@@ -2549,7 +2812,8 @@ export class CompaniesService {
|
|||||||
snapshot,
|
snapshot,
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
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);
|
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||||
return;
|
return;
|
||||||
@@ -2586,21 +2850,23 @@ export class CompaniesService {
|
|||||||
): void {
|
): void {
|
||||||
const state = buildCompanyIdentityState(company);
|
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.passportRequired) {
|
||||||
if (!state.owner.passportNumber) {
|
if (!state.owner.passportNumber) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Add the company owner's passport number before continuing.",
|
"Add the company owner's passport number before continuing.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
} else if (!state.owner.verified) {
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.owner.verified) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verify the company owner's identity with Fayda before continuing.",
|
"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) =>
|
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
@@ -2827,7 +3093,8 @@ export class CompaniesService {
|
|||||||
},
|
},
|
||||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.changeRequestRepo.create({
|
await this.changeRequestRepo.create({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Repository } from "typeorm";
|
import { FindOperator, Repository } from "typeorm";
|
||||||
|
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
|
|||||||
|
|
||||||
const COMPANY_ID = "company-1";
|
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
|
* 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.
|
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
||||||
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
|
|||||||
function mockRepositoryOver(rows: Row[]) {
|
function mockRepositoryOver(rows: Row[]) {
|
||||||
return {
|
return {
|
||||||
findOne: jest.fn(
|
findOne: jest.fn(
|
||||||
({ where }: { where: Partial<Row> & { companyId: string } }) =>
|
({
|
||||||
|
where,
|
||||||
|
}: {
|
||||||
|
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
|
||||||
|
}) =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
rows
|
rows
|
||||||
.filter(
|
.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
where.companyId === COMPANY_ID &&
|
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] ??
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
||||||
null,
|
null,
|
||||||
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
|
|||||||
expect(result?.id).toBe("pending");
|
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 () => {
|
it("returns the latest rejected request when nothing is pending", async () => {
|
||||||
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
||||||
COMPANY_ID,
|
COMPANY_ID,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
} from "./entities/company-change-request.entity";
|
} 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()
|
@Injectable()
|
||||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
|||||||
super(repo);
|
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(
|
async findPendingByCompanyId(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
): Promise<CompanyChangeRequest | null> {
|
): Promise<CompanyChangeRequest | null> {
|
||||||
return this.repository.findOne({
|
return this.repository.findOne({
|
||||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
|
||||||
order: { createdAt: "DESC" },
|
order: { createdAt: "DESC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,12 @@ export class CompanyInfoResponseDto {
|
|||||||
/**
|
/**
|
||||||
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
* 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: {
|
review: {
|
||||||
status: 'pending' | 'rejected';
|
status: 'pending' | 'rejected' | 'changes_requested';
|
||||||
note: string | null;
|
note: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
|
|||||||
const open =
|
const open =
|
||||||
changeRequest &&
|
changeRequest &&
|
||||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||||
|
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||||
? changeRequest
|
? changeRequest
|
||||||
: null;
|
: null;
|
||||||
this.review = open
|
this.review = open
|
||||||
? {
|
? {
|
||||||
status: open.status as 'pending' | 'rejected',
|
status: open.status as 'pending' | 'rejected' | 'changes_requested',
|
||||||
note: open.note ?? null,
|
note: open.note ?? null,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
|
|||||||
(p) => p.type === ProfileType.freightForwarder,
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||||
|
|
||||||
const complete = faydaRequired
|
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
||||||
? owner.verified && (!poaDue || poa.verified)
|
// 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);
|
: !passportRequired || Boolean(owner.passportNumber);
|
||||||
|
const complete = ownerProven && (!poaDue || poa.verified);
|
||||||
|
|
||||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,10 +68,12 @@ export class ProfileResponseDto {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
* settings page; `"rejected"`/`"changes_requested"` both surface the note and
|
||||||
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
* 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;
|
reviewNote: string | null;
|
||||||
pendingChanges: Record<string, any> | null;
|
pendingChanges: Record<string, any> | null;
|
||||||
|
|
||||||
@@ -127,7 +129,8 @@ export class ProfileResponseDto {
|
|||||||
const openReview =
|
const openReview =
|
||||||
changeRequest &&
|
changeRequest &&
|
||||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||||
|
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||||
? changeRequest
|
? changeRequest
|
||||||
: null;
|
: null;
|
||||||
this.reviewStatus =
|
this.reviewStatus =
|
||||||
@@ -135,7 +138,9 @@ export class ProfileResponseDto {
|
|||||||
? "pending"
|
? "pending"
|
||||||
: openReview?.status === ChangeRequestStatus.Rejected
|
: openReview?.status === ChangeRequestStatus.Rejected
|
||||||
? "rejected"
|
? "rejected"
|
||||||
: null;
|
: openReview?.status === ChangeRequestStatus.ChangesRequested
|
||||||
|
? "changes_requested"
|
||||||
|
: null;
|
||||||
this.reviewNote = openReview?.note ?? null;
|
this.reviewNote = openReview?.note ?? null;
|
||||||
this.pendingChanges = openReview?.snapshot ?? null;
|
this.pendingChanges = openReview?.snapshot ?? null;
|
||||||
this.identity = buildCompanyIdentityState(company);
|
this.identity = buildCompanyIdentityState(company);
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export class ResponseCompanyDto {
|
|||||||
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
approvedAt: Date | null;
|
||||||
|
|
||||||
constructor(company: Company) {
|
constructor(company: Company) {
|
||||||
this.id = company.id;
|
this.id = company.id;
|
||||||
@@ -135,5 +136,6 @@ export class ResponseCompanyDto {
|
|||||||
this.identity = buildCompanyIdentityState(company);
|
this.identity = buildCompanyIdentityState(company);
|
||||||
this.createdAt = company.createdAt;
|
this.createdAt = company.createdAt;
|
||||||
this.updatedAt = company.updatedAt;
|
this.updatedAt = company.updatedAt;
|
||||||
|
this.approvedAt = company.approvedAt ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
|
|||||||
/**
|
/**
|
||||||
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
* 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
|
* 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 live Company row) until a backoffice reviewer resolves it:
|
||||||
* the snapshot is applied — or rejects with a note, after which the customer can
|
* - Approved — the snapshot is applied to the live Company row.
|
||||||
* amend and resubmit.
|
* - 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 {
|
export enum ChangeRequestStatus {
|
||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
Approved = "approved",
|
Approved = "approved",
|
||||||
Rejected = "rejected",
|
Rejected = "rejected",
|
||||||
|
ChangesRequested = "changes_requested",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -61,6 +61,10 @@ export class Company extends BaseEntity {
|
|||||||
})
|
})
|
||||||
status!: CompanyStatus;
|
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 })
|
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
|
||||||
tin!: string;
|
tin!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ export class FilesController {
|
|||||||
@Query("download") download: string | undefined,
|
@Query("download") download: string | undefined,
|
||||||
@Res() res: Response,
|
@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
|
// 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
|
// 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 forceDownload = download === "1" || download === "true";
|
||||||
const disposition = forceDownload ? "attachment" : "inline";
|
const disposition = forceDownload ? "attachment" : "inline";
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,23 @@ export class FilesService {
|
|||||||
return record;
|
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
|
* 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
|
* (the customer sees it verbatim); any other verdict clears it, so a stale
|
||||||
@@ -360,8 +377,11 @@ export class FilesService {
|
|||||||
|
|
||||||
async streamById(
|
async streamById(
|
||||||
id: string,
|
id: string,
|
||||||
|
opts: { includeDeleted?: boolean } = {},
|
||||||
): Promise<{ stream: Readable; record: FileRecord }> {
|
): 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 objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||||
const stream = await this.minioService.getFileStream(objectName);
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
return { stream, record };
|
return { stream, record };
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
|
|||||||
|
|
||||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||||
type PaymentType = string
|
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"
|
type Currency = "ETB" | "USD"
|
||||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
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" })
|
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
|
||||||
referenceType?: string;
|
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
|
method!: PaymentMethod
|
||||||
|
|
||||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
||||||
|
|||||||
@@ -1,30 +1,42 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
forwardRef,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import { Public } from "@edr/api-common";
|
import {
|
||||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
|
PaymentEventDto,
|
||||||
|
MarkPaidResponseDto,
|
||||||
|
BillQueryRequestDto,
|
||||||
|
BillQueryResponseDto,
|
||||||
|
} from "./internal-payment.dto";
|
||||||
import { PaymentService } from "./payment.service";
|
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.
|
* Consumer side of the payment microservice's outbox relay. Only the payment service may
|
||||||
* WARNING: currently unauthenticated — anyone who can reach the API can mark
|
* call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8).
|
||||||
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
|
|
||||||
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
||||||
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
||||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||||
*/
|
*/
|
||||||
@ApiTags("Internal Payments")
|
@ApiTags("Internal Payments")
|
||||||
@Public()
|
@UseGuards(ServiceAuthGuard)
|
||||||
@Controller("internal/payments")
|
@Controller("internal/payments")
|
||||||
export class InternalPaymentController {
|
export class InternalPaymentController {
|
||||||
private readonly logger = new Logger(InternalPaymentController.name);
|
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")
|
@Post("mark-paid")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -36,4 +48,16 @@ export class InternalPaymentController {
|
|||||||
this.logger.log(`Marking payment ${event} as PAID`);
|
this.logger.log(`Marking payment ${event} as PAID`);
|
||||||
return this.paymentService.handlePaymentEvent(event);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,3 +51,30 @@ export class MarkPaidResponseDto {
|
|||||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||||
@ApiPropertyOptional() reason?: string;
|
@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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { HttpService } from "@nestjs/axios";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import {
|
import {
|
||||||
InitiatePaymentRequest,
|
InitiatePaymentRequest,
|
||||||
PaymentIntentSnapshot,
|
PaymentIntentSnapshot,
|
||||||
PaymentReferenceType,
|
PaymentReferenceType,
|
||||||
PaymentService,
|
PaymentService,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,85 +21,125 @@ import {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentClientService {
|
export class PaymentClientService {
|
||||||
private readonly logger = new Logger(PaymentClientService.name);
|
private readonly logger = new Logger(PaymentClientService.name);
|
||||||
private readonly baseUrl = (
|
private readonly baseUrl = (
|
||||||
// process.env.PAYMENT_API_URL ??
|
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||||
"https://paymentcallback.triaplc.com"
|
)
|
||||||
// "http://localhost:3003"
|
// "http://localhost:3003"
|
||||||
).replace(/\/$/, "");
|
.replace(/\/$/, "");
|
||||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
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). */
|
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
|
||||||
async initiate(request: InitiatePaymentRequest): Promise<PaymentIntentSnapshot> {
|
async initiate(
|
||||||
return this.call("POST", "/payments/initiate", request);
|
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
|
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider
|
||||||
* (reconcile-before-cancel). Live-queries every non-failed intent at the
|
* (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service;
|
||||||
* provider and registers any late capture found (flips it to SUCCEEDED and
|
* surface that as a BadRequest (retryable) rather than a 502, so the payer can
|
||||||
* emits payment.succeeded). `unverifiable: true` = could not confirm
|
* re-enter the code.
|
||||||
* "not paid" — the caller must NOT cancel/expire the order.
|
*/
|
||||||
*/
|
async confirmOtp(
|
||||||
async reconcileReference(
|
intentId: string,
|
||||||
referenceType: PaymentReferenceType,
|
otp: string,
|
||||||
referenceId: string,
|
): Promise<PaymentIntentSnapshot> {
|
||||||
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
try {
|
||||||
return this.call("POST", "/payments/reconcile", {
|
return await this.call<PaymentIntentSnapshot>(
|
||||||
service: PaymentService.FREIGHT,
|
"POST",
|
||||||
referenceType,
|
`/payments/intents/${intentId}/confirm`,
|
||||||
referenceId,
|
{ 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. */
|
private async call<T>(
|
||||||
async getIntentByReference(
|
method: "GET" | "POST",
|
||||||
referenceType: PaymentReferenceType,
|
path: string,
|
||||||
referenceId: string,
|
body?: unknown,
|
||||||
): Promise<PaymentIntentSnapshot | null> {
|
): Promise<T> {
|
||||||
const query = new URLSearchParams({
|
const url = `${this.baseUrl}${path}`;
|
||||||
service: PaymentService.FREIGHT,
|
try {
|
||||||
referenceType,
|
const response = await firstValueFrom(
|
||||||
referenceId,
|
this.http.request<T>({
|
||||||
});
|
method,
|
||||||
try {
|
url,
|
||||||
return await this.call("GET", `/payments/intents?${query.toString()}`);
|
data: body,
|
||||||
} catch (err) {
|
headers: this.serviceToken
|
||||||
if (err instanceof AxiosError && err.response?.status === 404) return null;
|
? { "x-service-token": this.serviceToken }
|
||||||
throw err;
|
: {},
|
||||||
}
|
}),
|
||||||
}
|
);
|
||||||
|
return response.data;
|
||||||
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
} catch (err) {
|
||||||
const url = `${this.baseUrl}${path}`;
|
if (err instanceof AxiosError && err.response) {
|
||||||
try {
|
if (err.response.status === 404) throw err;
|
||||||
const response = await firstValueFrom(
|
const detail =
|
||||||
this.http.request<T>({
|
(err.response.data as { message?: string | string[] })?.message ??
|
||||||
method,
|
err.message;
|
||||||
url,
|
this.logger.error(
|
||||||
data: body,
|
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||||
headers: this.serviceToken
|
);
|
||||||
? { "x-service-token": this.serviceToken }
|
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||||
: {},
|
}
|
||||||
}),
|
const message =
|
||||||
);
|
err instanceof Error && err.message ? err.message : String(err);
|
||||||
return response.data;
|
this.logger.error(
|
||||||
} catch (err) {
|
`payment service unreachable (${method} ${path}): ${message}`,
|
||||||
if (err instanceof AxiosError && err.response) {
|
);
|
||||||
if (err.response.status === 404) throw err;
|
throw new BadGatewayException("Payment service unreachable");
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
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,
|
ConfigModule,
|
||||||
forwardRef(() => BillingModule),
|
forwardRef(() => BillingModule),
|
||||||
// forwardRef(() => TrainSchedulingModule),
|
// forwardRef(() => TrainSchedulingModule),
|
||||||
|
|||||||
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal file
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,10 @@ export interface InitiateIntentInput {
|
|||||||
payerAccount?: string;
|
payerAccount?: string;
|
||||||
returnUrl?: string;
|
returnUrl?: string;
|
||||||
failureUrl?: 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 {
|
export interface InitiateIntentResult {
|
||||||
@@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
|||||||
CARD: "card",
|
CARD: "card",
|
||||||
DMONEY: "dmoney",
|
DMONEY: "dmoney",
|
||||||
CAC_BANK: "cac-bank",
|
CAC_BANK: "cac-bank",
|
||||||
|
CBE_BILL: "cbe-bill",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,8 +219,13 @@ export class PaymentService {
|
|||||||
|
|
||||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||||
try {
|
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({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.FREIGHT,
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
@@ -223,11 +233,15 @@ export class PaymentService {
|
|||||||
referenceId: input.referenceId,
|
referenceId: input.referenceId,
|
||||||
orderRef: input.orderRef,
|
orderRef: input.orderRef,
|
||||||
// amountMinor: input.amountMinor,
|
// 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,
|
currency: input.currency,
|
||||||
provider: input.method as ProviderMethod,
|
provider: input.method as ProviderMethod,
|
||||||
platform: input.platform,
|
platform: input.platform,
|
||||||
payerAccount: input.payerAccount,
|
payerAccount: input.payerAccount,
|
||||||
|
payerName: input.payerName,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
returnUrl:
|
returnUrl:
|
||||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||||
failureUrl:
|
failureUrl:
|
||||||
@@ -374,6 +388,53 @@ export class PaymentService {
|
|||||||
return this.formatIntentStatus(refreshed ?? local);
|
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
|
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||||
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||||
|
|||||||
@@ -60,8 +60,10 @@ export class RefundDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ClientActionDto {
|
export class ClientActionDto {
|
||||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
@ApiProperty({
|
||||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
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)" })
|
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -80,6 +82,17 @@ export class ClientActionDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||||
message?: string;
|
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 {
|
export class InitiateResponseDto {
|
||||||
|
|||||||
@@ -158,6 +158,14 @@ export class BookingJourneyService {
|
|||||||
this.events.emit('booking.completed', { bookingId });
|
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).
|
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||||
void this.completeMilestones(booking, [
|
void this.completeMilestones(booking, [
|
||||||
...(booking.tradeDirection === 'IMPORT'
|
...(booking.tradeDirection === 'IMPORT'
|
||||||
@@ -319,11 +327,19 @@ export class BookingJourneyService {
|
|||||||
RETURNING b.id, b.trade_direction`,
|
RETURNING b.id, b.trade_direction`,
|
||||||
[schedule.id, schedule.destinationStationId, now],
|
[schedule.id, schedule.destinationStationId, now],
|
||||||
);
|
);
|
||||||
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
||||||
if (row.trade_direction === 'DOMESTIC') {
|
if (row.trade_direction === 'DOMESTIC') {
|
||||||
this.events.emit('booking.completed', { bookingId: row.id });
|
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);
|
return rows.map((r) => r.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,25 +162,36 @@ describe('applyWagonOrderReversal', () => {
|
|||||||
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
|
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);
|
const reversed = applyWagonOrderReversal(plan, true);
|
||||||
// Physically-last wagon (was seq 3, wt-c) is now position 1.
|
// Physically-last wagon (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([3, 2, 1]);
|
||||||
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps each booking with its own wagon — only the position changes', () => {
|
it('keeps each booking with its own wagon — only the position changes', () => {
|
||||||
const reversed = applyWagonOrderReversal(plan, true);
|
const reversed = applyWagonOrderReversal(plan, true);
|
||||||
// The booking that was in the last wagon now sits at sequenceNo 1.
|
// 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(
|
expect(
|
||||||
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
|
(atPosition1?.allocations as { bookingId: string }[])[0].bookingId,
|
||||||
).toBe('BKG-C');
|
).toBe('BKG-C');
|
||||||
|
const atPosition3 = reversed.find((s) => s.sequenceNo === 3);
|
||||||
expect(
|
expect(
|
||||||
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
|
(atPosition3?.allocations as { bookingId: string }[])[0].bookingId,
|
||||||
).toBe('BKG-A');
|
).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', () => {
|
it('does not mutate the input plan', () => {
|
||||||
applyWagonOrderReversal(plan, true);
|
applyWagonOrderReversal(plan, true);
|
||||||
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||||
|
|||||||
@@ -470,15 +470,22 @@ export function planWagonsWithStock(params: {
|
|||||||
* sequenceNos, the snapshot re-sorts by them, and the board/allocation views all
|
* 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,
|
* read them — so the stored train order and the schedule order stay identical,
|
||||||
* just reversed. A false/absent flag returns the plan unchanged.
|
* 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(
|
export function applyWagonOrderReversal(
|
||||||
plan: WagonPlanSlot[],
|
plan: WagonPlanSlot[],
|
||||||
reverse: boolean | null | undefined,
|
reverse: boolean | null | undefined,
|
||||||
): WagonPlanSlot[] {
|
): WagonPlanSlot[] {
|
||||||
if (!reverse) return plan;
|
if (!reverse) return plan;
|
||||||
return [...plan]
|
return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index }));
|
||||||
.reverse()
|
|
||||||
.map((slot, index) => ({ ...slot, sequenceNo: index + 1 }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unbounded stock — used to compute pure demand for availability reporting. */
|
/** Unbounded stock — used to compute pure demand for availability reporting. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,11 +95,9 @@ export class WarehouseInspectionService {
|
|||||||
b.company_id AS "companyId",
|
b.company_id AS "companyId",
|
||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
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
|
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[inventoryId],
|
[inventoryId],
|
||||||
@@ -111,8 +109,11 @@ export class WarehouseInspectionService {
|
|||||||
readyForPickupAt: new Date(),
|
readyForPickupAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasLastMile =
|
// service_types.includes_last_mile is NOT read here — every service type
|
||||||
Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile);
|
// 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) {
|
if (row.bookingReference && hasLastMile) {
|
||||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
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 { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import {
|
import {
|
||||||
Between,
|
Between,
|
||||||
@@ -1339,8 +1339,11 @@ export class WarehouseInventoryService {
|
|||||||
bcu.seal_numbers AS "sealNumbers",
|
bcu.seal_numbers AS "sealNumbers",
|
||||||
bc.container_quantity AS "containerQuantity",
|
bc.container_quantity AS "containerQuantity",
|
||||||
bc.container_packaging_type AS "containerPackagingType",
|
bc.container_packaging_type AS "containerPackagingType",
|
||||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
-- service_types.includes_last_mile/first_mile are NOT read here: every
|
||||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
-- 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",
|
oy.code AS "origin",
|
||||||
dy.code AS "destination",
|
dy.code AS "destination",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
@@ -1351,8 +1354,7 @@ export class WarehouseInventoryService {
|
|||||||
b.cargo_total_weight_vgm AS "weight",
|
b.cargo_total_weight_vgm AS "weight",
|
||||||
b.payment_status AS "paymentStatus",
|
b.payment_status AS "paymentStatus",
|
||||||
b.status AS "status",
|
b.status AS "status",
|
||||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
|
||||||
fm.id AS "firstMileRequestId",
|
fm.id AS "firstMileRequestId",
|
||||||
fm.status AS "firstMileStatus",
|
fm.status AS "firstMileStatus",
|
||||||
fm.vehicle_id AS "firstMileVehicleId",
|
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 oy ON oy.id = b.origin_yard_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_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.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 freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
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",
|
bc.container_packaging_type AS "containerPackagingType",
|
||||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
-- No service_types OR here either — see eligibleBookings above.
|
||||||
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.id AS "firstMileRequestId",
|
||||||
fm.status AS "firstMileStatus",
|
fm.status AS "firstMileStatus",
|
||||||
v.plate_number AS "firstMileTruckPlateNumber",
|
v.plate_number AS "firstMileTruckPlateNumber",
|
||||||
@@ -1519,14 +1520,12 @@ export class WarehouseInventoryService {
|
|||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
b.company_id AS "companyId",
|
b.company_id AS "companyId",
|
||||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
|
||||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
|
||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
${primaryContactUserJoin('company')}
|
${primaryContactUserJoin('company')}
|
||||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_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.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 freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
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) {
|
for (const pending of pendingNotifications) {
|
||||||
void this.notifyOwnerInventoryReceived(pending.owner);
|
void this.notifyOwnerInventoryReceived(pending.owner);
|
||||||
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
|
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
|
||||||
|
if (dto.direction === 'EXPORT') void this.notifyCarriageAcceptanceReady(pending.bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -1979,11 +1979,10 @@ export class WarehouseInventoryService {
|
|||||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||||
ts.train_number AS "trainSchedule",
|
ts.train_number AS "trainSchedule",
|
||||||
inv.inspection_status AS "inspectionStatus",
|
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
|
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",
|
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
|
||||||
-- Multi-truck self-haul writes plates/drivers to
|
-- Multi-truck self-haul writes plates/drivers to
|
||||||
-- customer_truck_assignments and leaves the booking columns null,
|
-- customer_truck_assignments and leaves the booking columns null,
|
||||||
-- so read the assignments first and keep the legacy column as the
|
-- 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.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.companies company ON company.id = b.company_id
|
||||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_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 oy ON oy.id = b.origin_yard_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_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
|
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.
|
* 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 —
|
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
|
||||||
@@ -2689,16 +2789,16 @@ export class WarehouseInventoryService {
|
|||||||
if (!bookingId) return;
|
if (!bookingId) return;
|
||||||
const [booking] = await this.dataSource.query(
|
const [booking] = await this.dataSource.query(
|
||||||
`SELECT reference,
|
`SELECT reference,
|
||||||
last_mile_delivery_address AS "lastMileDeliveryAddress",
|
last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
|
||||||
FROM freight.bookings b
|
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
|
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
const hasLastMile =
|
// service_types.includes_last_mile is NOT read here — every service type
|
||||||
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
|
// 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;
|
if (!booking?.reference || !hasLastMile) return;
|
||||||
await this.lastMileService.acceptBooking(booking.reference);
|
await this.lastMileService.acceptBooking(booking.reference);
|
||||||
}
|
}
|
||||||
@@ -2809,6 +2909,10 @@ export class WarehouseInventoryService {
|
|||||||
return saved.id;
|
return saved.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (dto.bookingId && bookingDirection === 'EXPORT') {
|
||||||
|
void this.notifyCarriageAcceptanceReady(dto.bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3057,14 +3161,15 @@ export class WarehouseInventoryService {
|
|||||||
*/
|
*/
|
||||||
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
|
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
|
||||||
const runner = manager ?? this.dataSource;
|
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(
|
const [row]: Array<{ ok: number }> = await runner.query(
|
||||||
`SELECT 1 AS ok
|
`SELECT 1 AS ok
|
||||||
FROM freight.bookings b
|
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
|
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||||
AND (b.customer_truck_assigned_at IS NOT NULL
|
AND (b.customer_truck_assigned_at IS NOT NULL
|
||||||
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
|
OR NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL)`,
|
||||||
AND COALESCE(st.includes_last_mile, false) = false))`,
|
|
||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
return Boolean(row);
|
return Boolean(row);
|
||||||
@@ -5978,6 +6083,36 @@ export class WarehouseInventoryService {
|
|||||||
return booking ?? {};
|
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: {
|
private async notifyOwnerInventoryReceived(params: {
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
ownerName?: string | null;
|
ownerName?: string | null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
24
apps/edr-freight-api/src/scripts/migrate.ts
Normal file
24
apps/edr-freight-api/src/scripts/migrate.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -118,6 +118,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
|||||||
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
|
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
|
||||||
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
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 DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||||
@@ -411,6 +413,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
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",
|
label: "Dispatch Queue",
|
||||||
href: "/dashboard/dispatch-queue",
|
href: "/dashboard/dispatch-queue",
|
||||||
@@ -1060,6 +1074,8 @@ const App = () => {
|
|||||||
<Route path="intercity" element={<IntercityPage />} />
|
<Route path="intercity" element={<IntercityPage />} />
|
||||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||||
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
<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="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
@@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth";
|
|||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { fetchViewableFile } from "@/services/files.service";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
import type { Company } from "@/types/customer";
|
||||||
import { formatDate, humanize } from "./format";
|
import { formatDate, humanize } from "./format";
|
||||||
|
|
||||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
/** 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",
|
companyName: "Company name",
|
||||||
companyEmail: "Company email",
|
companyEmail: "Company email",
|
||||||
companyPhone: "Company phone",
|
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. */
|
/** 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 c = company as unknown as Record<string, unknown>;
|
||||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||||
const map: Record<string, unknown> = {
|
const map: Record<string, unknown> = {
|
||||||
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffRow({
|
export function DiffRow({
|
||||||
label,
|
label,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
@@ -201,8 +200,9 @@ function DiffRow({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Backoffice review surface for a customer's staged profile edits. Shows the
|
* Backoffice review surface for a customer's staged profile edits. Shows the
|
||||||
* pending change request as a proposed-vs-current diff with Approve / Reject
|
* pending change request as a proposed-vs-current diff with Approve / Reject /
|
||||||
* (with note) actions, plus a short history of past decisions.
|
* Request changes actions. Past decisions live in the History tab's unified
|
||||||
|
* timeline (see {@link CompanyTimeline}), not here.
|
||||||
*/
|
*/
|
||||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
const reject = useMutation(
|
const reject = useMutation(
|
||||||
api.customers.rejectChangeRequest.mutationOptions(),
|
api.customers.rejectChangeRequest.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
const requestChanges = useMutation(
|
||||||
|
api.customers.requestChangeRequestChanges.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
const { view, viewer } = useFileViewer();
|
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 [note, setNote] = useState("");
|
||||||
|
|
||||||
const requests = query.data ?? [];
|
const requests = query.data ?? [];
|
||||||
const pending = requests.find((r) => r.status === "pending");
|
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
|
const proposedKeys = pending
|
||||||
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||||
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
const licenseChanges = pending?.licenseChanges ?? [];
|
const licenseChanges = pending?.licenseChanges ?? [];
|
||||||
const documentChanges = pending?.documentChanges ?? [];
|
const documentChanges = pending?.documentChanges ?? [];
|
||||||
|
|
||||||
const confirmReject = () => {
|
const confirmAction = () => {
|
||||||
if (!rejectId) return;
|
if (!actionTarget) return;
|
||||||
reject.mutate(
|
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
|
||||||
{ id: rejectId, note: note.trim() },
|
mutation.mutate(
|
||||||
|
{ id: actionTarget.id, note: note.trim() },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setRejectId(null);
|
setActionTarget(null);
|
||||||
setNote("");
|
setNote("");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</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 ? (
|
{proposedKeys.length > 0 ? (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
{proposedKeys.map((key) => (
|
{proposedKeys.map((key) => (
|
||||||
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRejectId(pending.id);
|
setActionTarget({ id: pending.id, kind: "reject" });
|
||||||
setNote("");
|
setNote("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
onClick={() => {
|
||||||
|
setActionTarget({ id: pending.id, kind: "request-changes" });
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request changes
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={approve.isPending}
|
loading={approve.isPending}
|
||||||
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
</Card>
|
</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
|
<Modal
|
||||||
opened={rejectId !== null}
|
opened={actionTarget !== null}
|
||||||
onClose={() => setRejectId(null)}
|
onClose={() => setActionTarget(null)}
|
||||||
title="Reject changes"
|
title={
|
||||||
|
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
|
||||||
|
}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
<Alert
|
||||||
The customer will see this note and can amend and resubmit.
|
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>
|
</Alert>
|
||||||
<Textarea
|
<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."
|
placeholder="e.g. The company address doesn't match the trade license."
|
||||||
autosize
|
autosize
|
||||||
minRows={3}
|
minRows={3}
|
||||||
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setRejectId(null)}
|
onClick={() => setActionTarget(null)}
|
||||||
disabled={reject.isPending}
|
disabled={reject.isPending || requestChanges.isPending}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||||
loading={reject.isPending}
|
loading={reject.isPending || requestChanges.isPending}
|
||||||
disabled={note.trim().length === 0}
|
disabled={note.trim().length === 0}
|
||||||
onClick={confirmReject}
|
onClick={confirmAction}
|
||||||
>
|
>
|
||||||
Reject changes
|
{actionTarget?.kind === "reject"
|
||||||
|
? "Reject changes"
|
||||||
|
: "Request changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export {
|
|||||||
ChangeRequestReview,
|
ChangeRequestReview,
|
||||||
ChangeRequestPendingBadge,
|
ChangeRequestPendingBadge,
|
||||||
} from "./ChangeRequestReview";
|
} from "./ChangeRequestReview";
|
||||||
|
export { CompanyTimeline } from "./CompanyTimeline";
|
||||||
export {
|
export {
|
||||||
RequestDocumentChangeModal,
|
RequestDocumentChangeModal,
|
||||||
type RequestDocumentChangeModalProps,
|
type RequestDocumentChangeModalProps,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
WAREHOUSE_ZONE_TYPES,
|
WAREHOUSE_ZONE_TYPES,
|
||||||
WAREHOUSE_STATUSES,
|
WAREHOUSE_STATUSES,
|
||||||
INVENTORY_STATUSES,
|
INVENTORY_STATUSES,
|
||||||
|
type ImportUnloadedItem,
|
||||||
type Warehouse,
|
type Warehouse,
|
||||||
|
type WarehouseInventoryItem,
|
||||||
type WarehouseYard,
|
type WarehouseYard,
|
||||||
} from '@/types/warehouse';
|
} 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;
|
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
|
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
||||||
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export const QUERY_KEYS = {
|
|||||||
["customers", "detail", id, "reset-target"] as const,
|
["customers", "detail", id, "reset-target"] as const,
|
||||||
changeRequests: (id: string) =>
|
changeRequests: (id: string) =>
|
||||||
["customers", "detail", id, "change-requests"] as const,
|
["customers", "detail", id, "change-requests"] as const,
|
||||||
|
revisions: (id: string) =>
|
||||||
|
["customers", "detail", id, "revisions"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
INVOICES: {
|
INVOICES: {
|
||||||
|
|||||||
@@ -78,10 +78,13 @@ export const URL_CONSTANTS = {
|
|||||||
`/companies/company-profiles/${profileId}/status`,
|
`/companies/company-profiles/${profileId}/status`,
|
||||||
CHANGE_REQUESTS: (companyId: string) =>
|
CHANGE_REQUESTS: (companyId: string) =>
|
||||||
`/companies/${companyId}/change-requests`,
|
`/companies/${companyId}/change-requests`,
|
||||||
|
REVISIONS: (companyId: string) => `/companies/${companyId}/revisions`,
|
||||||
CHANGE_REQUEST_APPROVE: (id: string) =>
|
CHANGE_REQUEST_APPROVE: (id: string) =>
|
||||||
`/companies/change-requests/${id}/approve`,
|
`/companies/change-requests/${id}/approve`,
|
||||||
CHANGE_REQUEST_REJECT: (id: string) =>
|
CHANGE_REQUEST_REJECT: (id: string) =>
|
||||||
`/companies/change-requests/${id}/reject`,
|
`/companies/change-requests/${id}/reject`,
|
||||||
|
CHANGE_REQUEST_REQUEST_CHANGES: (id: string) =>
|
||||||
|
`/companies/change-requests/${id}/request-changes`,
|
||||||
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
|
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
|
||||||
`/companies/documents/${fileId}/request-change`,
|
`/companies/documents/${fileId}/request-change`,
|
||||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||||
@@ -132,6 +135,8 @@ export const URL_CONSTANTS = {
|
|||||||
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
|
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
|
||||||
CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
|
CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
|
||||||
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
|
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
|
||||||
|
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
|
||||||
|
`/bookings/${id}/carriage-acceptance-sheet`,
|
||||||
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
||||||
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
||||||
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
useBookingMutations,
|
useBookingMutations,
|
||||||
} from "@/hooks/bookings/useBookings";
|
} from "@/hooks/bookings/useBookings";
|
||||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
|
||||||
export default function BookingRequestDetailPage() {
|
export default function BookingRequestDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -269,6 +272,33 @@ export default function BookingRequestDetailPage() {
|
|||||||
View / sign contract
|
View / sign contract
|
||||||
</Button>
|
</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 && (
|
{booking.customsClearingEnabled && (
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
|
History,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
IdCard,
|
IdCard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
ChangeRequestPendingBadge,
|
ChangeRequestPendingBadge,
|
||||||
ChangeRequestReview,
|
ChangeRequestReview,
|
||||||
CompanyStatusBadge,
|
CompanyStatusBadge,
|
||||||
|
CompanyTimeline,
|
||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
InvoiceStatusBadge,
|
InvoiceStatusBadge,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
@@ -64,6 +66,7 @@ import {
|
|||||||
} from "@/services/files.service";
|
} from "@/services/files.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type {
|
import type {
|
||||||
|
Company,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
CustomerBooking,
|
CustomerBooking,
|
||||||
CustomerDocument,
|
CustomerDocument,
|
||||||
@@ -78,6 +81,32 @@ import {
|
|||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@edr/ui-common";
|
} 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 }) {
|
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
@@ -689,6 +718,9 @@ export default function CustomerDetailPage() {
|
|||||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||||
Invoices
|
Invoices
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||||
|
History
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{/* OVERVIEW */}
|
{/* OVERVIEW */}
|
||||||
@@ -757,6 +789,18 @@ export default function CustomerDetailPage() {
|
|||||||
<InfoField label="TIN" value={company.tin} />
|
<InfoField label="TIN" value={company.tin} />
|
||||||
<InfoField label="VAT number" value={company.vatNumber} />
|
<InfoField label="VAT number" value={company.vatNumber} />
|
||||||
<InfoField label="FAN number" value={company.fanNumber} />
|
<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
|
<InfoField
|
||||||
label="Owner identity"
|
label="Owner identity"
|
||||||
value={
|
value={
|
||||||
@@ -800,18 +844,29 @@ export default function CustomerDetailPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||||
<Text fw={600} c="edr-text">
|
<Group gap="xs" wrap="nowrap">
|
||||||
eTrade registration
|
<Text fw={600} c="edr-text">
|
||||||
</Text>
|
eTrade registration
|
||||||
{hasEtradeRecord ? (
|
</Text>
|
||||||
<Badge size="sm" color="edr-green" variant="light">
|
{hasEtradeRecord ? (
|
||||||
Verified with eTrade
|
<Badge size="sm" color="edr-green" variant="light">
|
||||||
</Badge>
|
Verified with eTrade
|
||||||
) : (
|
</Badge>
|
||||||
<Badge size="sm" color="gray" variant="light">
|
) : (
|
||||||
No eTrade record
|
<Badge size="sm" color="gray" variant="light">
|
||||||
</Badge>
|
No eTrade record
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{hasEtradeRecord && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
aria-label="Download TIN record"
|
||||||
|
onClick={() => downloadTinRecord(company)}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{hasEtradeRecord ? (
|
{hasEtradeRecord ? (
|
||||||
@@ -1235,6 +1290,11 @@ export default function CustomerDetailPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* HISTORY */}
|
||||||
|
<Tabs.Panel value="history" pt="lg">
|
||||||
|
<CompanyTimeline company={company} />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<RequestDocumentChangeModal
|
<RequestDocumentChangeModal
|
||||||
|
|||||||
@@ -245,6 +245,16 @@ export default function CustomersPage() {
|
|||||||
</Text>
|
</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>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
required: true,
|
required: true,
|
||||||
description: "Pre-filled from the truck type — override only for a one-off",
|
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: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||||
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
|
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
|
||||||
{ name: "description", label: "Description", type: "textarea" },
|
{ name: "description", label: "Description", type: "textarea" },
|
||||||
@@ -157,6 +158,7 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
fuelType: "DIESEL",
|
fuelType: "DIESEL",
|
||||||
capacity: 0,
|
capacity: 0,
|
||||||
|
pricePerKm: 0,
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
availability: "FREE",
|
availability: "FREE",
|
||||||
description: "",
|
description: "",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { driversService, type Driver } from "@/services/drivers.service";
|
import { driversService, type Driver } from "@/services/drivers.service";
|
||||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||||
|
import { toReleaseInventoryItem } from "@/components/warehouses/options";
|
||||||
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
||||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||||
@@ -299,40 +300,6 @@ const requestedDate = (r: LastMileRecord) => {
|
|||||||
const serviceTypeName = (r: LastMileRecord) =>
|
const serviceTypeName = (r: LastMileRecord) =>
|
||||||
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
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 = (
|
const releasePrefillFromLastMile = (
|
||||||
record: LastMileRecord,
|
record: LastMileRecord,
|
||||||
row?: ImportUnloadedItem | null,
|
row?: ImportUnloadedItem | null,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
|||||||
{ value: "card", label: "Card" },
|
{ value: "card", label: "Card" },
|
||||||
{ value: "dmoney", label: "D-Money" },
|
{ value: "dmoney", label: "D-Money" },
|
||||||
{ value: "cac-bank", label: "CAC Bank" },
|
{ value: "cac-bank", label: "CAC Bank" },
|
||||||
|
{ value: "cbe-bill", label: "CBE Bill" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment, useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -27,14 +28,22 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
|||||||
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
||||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
|
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 { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
import { useListControls } from "@/hooks/useListControls";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { lastMileService } from "@/services/last-mile.service";
|
import { lastMileService } from "@/services/last-mile.service";
|
||||||
import { warehouseService, type LastMileArrivalTruck } from "@/services/warehouse.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.
|
* 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;
|
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) =>
|
const money = (amount: number, currency: string) =>
|
||||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||||
|
|
||||||
@@ -109,6 +132,8 @@ interface TruckRow {
|
|||||||
vehicleId: string | null;
|
vehicleId: string | null;
|
||||||
arrivedAt: string | null;
|
arrivedAt: string | null;
|
||||||
departedAt: 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 }) {
|
function TruckRows({ group }: { group: BookingGroup }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
const [detentionOpen, setDetentionOpen] = useState(false);
|
const [detentionOpen, setDetentionOpen] = useState(false);
|
||||||
const [gateTimesOpen, setGateTimesOpen] = 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({
|
const edrQuery = useQuery({
|
||||||
queryKey: ["booking-edr-trucks", group.bookingId],
|
queryKey: ["booking-edr-trucks", group.bookingId],
|
||||||
@@ -194,6 +226,15 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
vehicleId: t.vehicleId,
|
vehicleId: t.vehicleId,
|
||||||
arrivedAt: t.arrivedAt,
|
arrivedAt: t.arrivedAt,
|
||||||
departedAt: t.departedAt,
|
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),
|
...costsFor(containers),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -208,11 +249,39 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
vehicleId: null,
|
vehicleId: null,
|
||||||
arrivedAt: t.arrivedAt ?? null,
|
arrivedAt: t.arrivedAt ?? null,
|
||||||
departedAt: t.departedAt ?? null,
|
departedAt: t.departedAt ?? null,
|
||||||
|
prefill: {
|
||||||
|
truckPlateNumber: t.plateNumber,
|
||||||
|
driverName: t.driverName,
|
||||||
|
truckType: t.truckType,
|
||||||
|
containerNumber: containers.join(", "),
|
||||||
|
},
|
||||||
...costsFor(containers),
|
...costsFor(containers),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer);
|
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 (
|
const openDocument = async (
|
||||||
kind: "release" | "handover",
|
kind: "release" | "handover",
|
||||||
inventoryId: string,
|
inventoryId: string,
|
||||||
@@ -267,6 +336,18 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
|
|
||||||
return (
|
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) => {
|
{trucks.map((t, idx) => {
|
||||||
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
|
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
|
||||||
const primaryId = t.inventoryIds[0] ?? null;
|
const primaryId = t.inventoryIds[0] ?? null;
|
||||||
@@ -337,6 +418,21 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<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
|
<Menu.Item
|
||||||
leftSection={<FileText size={14} />}
|
leftSection={<FileText size={14} />}
|
||||||
disabled={!primaryId}
|
disabled={!primaryId}
|
||||||
@@ -388,6 +484,12 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
onClose={() => setInspectId(null)}
|
onClose={() => setInspectId(null)}
|
||||||
inventoryId={inspectId}
|
inventoryId={inspectId}
|
||||||
/>
|
/>
|
||||||
|
<ReleaseOrderModal
|
||||||
|
opened={Boolean(release)}
|
||||||
|
onClose={closeRelease}
|
||||||
|
item={release?.item ?? null}
|
||||||
|
truckPrefill={release?.prefill ?? null}
|
||||||
|
/>
|
||||||
{isEdr && (
|
{isEdr && (
|
||||||
<>
|
<>
|
||||||
<TruckDetentionModal
|
<TruckDetentionModal
|
||||||
@@ -459,16 +561,11 @@ export default function ImportTrucksPage() {
|
|||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th w={40} />
|
<Table.Th w={40} />
|
||||||
<Table.Th>Plate</Table.Th>
|
{TRUCK_COLUMNS.map((label) => (
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th key={label} ta={label === "Actions" ? "right" : undefined}>
|
||||||
<Table.Th>Containers</Table.Th>
|
{label}
|
||||||
<Table.Th>Truck Arrival</Table.Th>
|
</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>
|
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
|
|||||||
@@ -150,8 +150,9 @@ function AllocationRules() {
|
|||||||
.filter((yard) => yard.code)
|
.filter((yard) => yard.code)
|
||||||
.map((yard) => ({
|
.map((yard) => ({
|
||||||
value: yard.code,
|
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 = () => {
|
const resetForm = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@@ -223,7 +224,11 @@ function AllocationRules() {
|
|||||||
{
|
{
|
||||||
id: 'targetYard',
|
id: 'targetYard',
|
||||||
header: 'Target yard',
|
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',
|
id: 'active',
|
||||||
@@ -307,7 +312,8 @@ function AllocationRules() {
|
|||||||
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
|
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
|
||||||
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
|
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
|
||||||
{form.containerStatus.trim() ? ` and container status ${form.containerStatus.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>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
CompanyListFilter,
|
CompanyListFilter,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
|
CompanyRevision,
|
||||||
CompanyStats,
|
CompanyStats,
|
||||||
CustomerBooking,
|
CustomerBooking,
|
||||||
CustomerDocument,
|
CustomerDocument,
|
||||||
@@ -2778,6 +2779,13 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
|
({ 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>(
|
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
|
||||||
"customers",
|
"customers",
|
||||||
"approveChangeRequest",
|
"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
|
* Ask the customer to correct one document. Invalidates the documents list
|
||||||
* and the company itself, since an open request blocks role approval.
|
* and the company itself, since an open request blocks role approval.
|
||||||
|
|||||||
@@ -455,6 +455,13 @@ export const bookingsService = {
|
|||||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
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[]> => {
|
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
|
||||||
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
|
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
|
||||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
CompanyListFilter,
|
CompanyListFilter,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
|
CompanyRevision,
|
||||||
CompanyStats,
|
CompanyStats,
|
||||||
CustomerBooking,
|
CustomerBooking,
|
||||||
CustomerDocument,
|
CustomerDocument,
|
||||||
@@ -146,6 +147,13 @@ export const customersService = {
|
|||||||
.then((r) => r.data);
|
.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. */
|
/** Approve a pending change request — applies the proposed changes. */
|
||||||
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
|
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
|
||||||
return apiClient
|
return apiClient
|
||||||
@@ -165,6 +173,19 @@ export const customersService = {
|
|||||||
.then((r) => r.data);
|
.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
|
* Ask the customer to correct one uploaded document. Narrower than rejecting
|
||||||
* the whole role: the customer keeps their other documents and only re-uploads
|
* the whole role: the customer keeps their other documents and only re-uploads
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export type PaymentMethod =
|
|||||||
| "waafi"
|
| "waafi"
|
||||||
| "card"
|
| "card"
|
||||||
| "dmoney"
|
| "dmoney"
|
||||||
| "cac-bank";
|
| "cac-bank"
|
||||||
|
| "cbe-bill";
|
||||||
|
|
||||||
export interface PaymentRow {
|
export interface PaymentRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ export interface CompanyProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Lifecycle of a staged customer profile-edit review. */
|
/** 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. */
|
/** A staged business-license add/remove on one profile, awaiting review. */
|
||||||
export interface LicenseChangeIntent {
|
export interface LicenseChangeIntent {
|
||||||
@@ -110,6 +114,33 @@ export interface CompanyChangeRequest {
|
|||||||
updatedAt: string;
|
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. */
|
/** The channel a customer's password-reset link is delivered over. */
|
||||||
export type ResetChannel = "email" | "phone";
|
export type ResetChannel = "email" | "phone";
|
||||||
|
|
||||||
@@ -215,6 +246,7 @@ export interface Company {
|
|||||||
onboardingCompleted?: boolean;
|
onboardingCompleted?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
approvedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -883,6 +883,9 @@ export function AppLayout({
|
|||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "##f8fafc",
|
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}
|
{children}
|
||||||
|
|||||||
@@ -28,6 +28,31 @@ interface ETradeInfoProps {
|
|||||||
|
|
||||||
const isValidTin = (tin: string) => tin.length === 10;
|
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({
|
export default function ETradeInfo({
|
||||||
tin,
|
tin,
|
||||||
register,
|
register,
|
||||||
@@ -123,6 +148,17 @@ export default function ETradeInfo({
|
|||||||
{isLoading ? "Getting..." : "Get Data"}
|
{isLoading ? "Getting..." : "Get Data"}
|
||||||
</Button>
|
</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>
|
</Group>
|
||||||
|
|
||||||
{notFound && (
|
{notFound && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link } from "react-router-dom";
|
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 { api } from "@/services/api";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import type { OnboardingRequirements } from "@/services/companies.service";
|
import type { OnboardingRequirements } from "@/services/companies.service";
|
||||||
@@ -150,18 +150,46 @@ export default function OnboardingResumeBanner({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Post-onboarding review banner. Surfaces (in priority order):
|
* 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
|
* 1. A pending profile-edit review — the whole account is locked until an admin
|
||||||
* approves the submitted changes.
|
* approves the submitted changes.
|
||||||
* 2. A rejected profile-edit review — links to Settings to amend & resubmit.
|
* 2. Backoffice requested changes — soft: same edit-and-resubmit call to
|
||||||
* 3. Per-operational-profile approval — bookings unlock as each role clears.
|
* 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.
|
* Self-hides when there's nothing outstanding.
|
||||||
*/
|
*/
|
||||||
export function AccountReviewBanner() {
|
export function AccountReviewBanner() {
|
||||||
const { company, reviewStatus, reviewNote } = useAuth();
|
const { company, companyStatus, reviewStatus, reviewNote } = useAuth();
|
||||||
const profiles = company?.company?.companyProfiles ?? [];
|
const profiles = company?.company?.companyProfiles ?? [];
|
||||||
const pending = profiles.filter((p) => p.status === "pending");
|
const pending = profiles.filter((p) => p.status === "pending");
|
||||||
const approved = profiles.filter((p) => p.status === "active");
|
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.
|
// 1. Profile-edit review pending — the account-wide lock.
|
||||||
if (reviewStatus === "pending") {
|
if (reviewStatus === "pending") {
|
||||||
return (
|
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 & resubmit
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Profile-edit review rejected — prompt to fix & resubmit.
|
||||||
if (reviewStatus === "rejected") {
|
if (reviewStatus === "rejected") {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
|
<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).
|
// 4. Company approved and awaiting its first operational profile — nothing
|
||||||
if (profiles.length === 0 || pending.length === 0) return null;
|
// 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
|
const pendingLabel = pending
|
||||||
.map((p) => p.type.replace(/_/g, " "))
|
.map((p) => p.type.replace(/_/g, " "))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -127,6 +128,7 @@ export default function OnboardingWizardDialog({
|
|||||||
}: OnboardingWizardDialogProps) {
|
}: OnboardingWizardDialogProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
|
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
|
||||||
|
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||||
|
|
||||||
const existingProfiles = company?.company?.companyProfiles ?? [];
|
const existingProfiles = company?.company?.companyProfiles ?? [];
|
||||||
const companyAlreadyStarted = Boolean(company?.company?.id);
|
const companyAlreadyStarted = Boolean(company?.company?.id);
|
||||||
@@ -409,7 +411,9 @@ export default function OnboardingWizardDialog({
|
|||||||
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
|
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
|
||||||
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
|
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
|
||||||
const effectiveResumeStep: FormStep =
|
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 = {
|
const formProps = {
|
||||||
documentSettingCode: resolvedDocumentSettingCode,
|
documentSettingCode: resolvedDocumentSettingCode,
|
||||||
@@ -450,9 +454,10 @@ export default function OnboardingWizardDialog({
|
|||||||
withCloseButton={!completed}
|
withCloseButton={!completed}
|
||||||
closeOnClickOutside={false}
|
closeOnClickOutside={false}
|
||||||
closeOnEscape={!completed}
|
closeOnEscape={!completed}
|
||||||
|
fullScreen={isMobile}
|
||||||
size={1440}
|
size={1440}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
padding="xl"
|
padding={isMobile ? "md" : "xl"}
|
||||||
centered
|
centered
|
||||||
keepMounted
|
keepMounted
|
||||||
scrollAreaComponent={ScrollArea.Autosize}
|
scrollAreaComponent={ScrollArea.Autosize}
|
||||||
@@ -464,6 +469,9 @@ export default function OnboardingWizardDialog({
|
|||||||
title: {
|
title: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
body: isMobile
|
||||||
|
? { paddingBottom: "calc(100px + env(safe-area-inset-bottom))" }
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
completed ? null : (
|
completed ? null : (
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export const URL_CONSTANTS = {
|
|||||||
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
||||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||||
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
|
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) =>
|
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
|
||||||
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
|
`/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_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
|
||||||
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
||||||
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||||
|
CONFIRM_INVOICE_OTP: (id: string) =>
|
||||||
|
`/api/billing/my-invoices/${id}/confirm`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INVOICES: {
|
WAREHOUSE_INVOICES: {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function SupportWidget() {
|
|||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
return (
|
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}>
|
<Transition mounted={open} transition="pop-bottom-right" duration={200}>
|
||||||
{(styles) => (
|
{(styles) => (
|
||||||
<div style={styles} className="mb-3">
|
<div style={styles} className="mb-3">
|
||||||
@@ -53,40 +53,44 @@ export function SupportWidget() {
|
|||||||
<Transition mounted={!open} transition="pop" duration={150}>
|
<Transition mounted={!open} transition="pop" duration={150}>
|
||||||
{(styles) => (
|
{(styles) => (
|
||||||
<div style={styles} className="flex justify-end">
|
<div style={styles} className="flex justify-end">
|
||||||
<Indicator
|
{/* Icon+label stay recognizable at rest, just tucked down a bit;
|
||||||
label={unread > 9 ? "9+" : unread}
|
lifts fully into place on hover/focus. */}
|
||||||
size={20}
|
<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">
|
||||||
offset={8}
|
<Indicator
|
||||||
color="red"
|
label={unread > 9 ? "9+" : unread}
|
||||||
disabled={unread === 0}
|
size={20}
|
||||||
processing
|
offset={8}
|
||||||
withBorder
|
color="red"
|
||||||
>
|
disabled={unread === 0}
|
||||||
<button
|
processing
|
||||||
type="button"
|
withBorder
|
||||||
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. */}
|
<button
|
||||||
{unread === 0 && (
|
type="button"
|
||||||
<span className="pointer-events-none absolute -inset-1 animate-pulse rounded-full ring-2 ring-edr-primary/50 motion-reduce:animate-none" />
|
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">
|
<span className="relative grid size-[42px] shrink-0 place-items-center rounded-full bg-white/20">
|
||||||
<Headset size={22} />
|
<Headset size={22} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="relative hidden text-left leading-tight sm:block">
|
<span className="relative hidden text-left leading-tight sm:block">
|
||||||
<span className="block text-sm font-semibold whitespace-nowrap">
|
<span className="block text-sm font-semibold whitespace-nowrap">
|
||||||
👋 Need help?
|
👋 Need help?
|
||||||
|
</span>
|
||||||
|
<span className="block text-[11px] whitespace-nowrap opacity-85">
|
||||||
|
Chat with our team
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[11px] whitespace-nowrap opacity-85">
|
</button>
|
||||||
Chat with our team
|
</Indicator>
|
||||||
</span>
|
</div>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Indicator>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal file
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal 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>;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -10,9 +10,8 @@ import {
|
|||||||
PackagePlus,
|
PackagePlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
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 { invoicesService } from "@/services/invoices.service";
|
||||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
@@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
isPayable(inv.status),
|
isPayable(inv.status),
|
||||||
)?.id;
|
)?.id;
|
||||||
|
|
||||||
const payMutation = useMutation({
|
const pay = useInvoicePayment();
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
@@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payItem !== null}
|
opened={payItem !== null}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!payMutation.isPending) {
|
if (!pay.processing) {
|
||||||
setPayItem(null);
|
setPayItem(null);
|
||||||
payMutation.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
currency={undefined}
|
currency={undefined}
|
||||||
processing={payMutation.isPending}
|
processing={pay.processing}
|
||||||
error={
|
error={
|
||||||
payMutation.isError
|
pay.error ??
|
||||||
? payMutation.error instanceof Error
|
(payItemInvoices.length > 0 && !payableInvoiceId
|
||||||
? payMutation.error.message
|
? "No payable invoice found for this booking yet. Please refresh or contact support."
|
||||||
: "Could not start payment. Please try again."
|
: null)
|
||||||
: null
|
}
|
||||||
|
otp={pay.otp}
|
||||||
|
onConfirm={(method, payerAccount) =>
|
||||||
|
payableInvoiceId &&
|
||||||
|
pay.pay(payableInvoiceId, method, payerAccount)
|
||||||
}
|
}
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
|
||||||
/>
|
/>
|
||||||
</ModalSafeWrapper>
|
</ModalSafeWrapper>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -287,9 +287,31 @@ export default function SettingsPage() {
|
|||||||
icon={<Clock size={18} />}
|
icon={<Clock size={18} />}
|
||||||
title="Changes submitted for review"
|
title="Changes submitted for review"
|
||||||
>
|
>
|
||||||
Your recent changes are awaiting administrator approval. Editing is
|
Your recent changes are awaiting administrator approval. Company
|
||||||
disabled until the review is complete — you'll be notified once it's
|
details and documents can't be edited until the review is complete —
|
||||||
approved or if any changes are requested.
|
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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{reviewStatus === "rejected" && (
|
{reviewStatus === "rejected" && (
|
||||||
@@ -358,9 +380,17 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* While a change request is pending, every panel's inputs + submit
|
{/* While a change request is pending, the reviewed panels' inputs +
|
||||||
buttons are disabled via the native fieldset; tab switching stays
|
submit buttons are disabled via the native fieldset; tab switching
|
||||||
enabled so the customer can still review what they submitted. */}
|
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">
|
<Tabs.Panel value="company">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
||||||
@@ -368,19 +398,13 @@ export default function SettingsPage() {
|
|||||||
<OperationalServicesCard profile={profile} />
|
<OperationalServicesCard profile={profile} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="contact">
|
<Tabs.Panel value="contact">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<TabContactPerson profile={profile} mode="edit" />
|
||||||
<TabContactPerson profile={profile} mode="edit" />
|
|
||||||
</Fieldset>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="gm">
|
<Tabs.Panel value="gm">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<TabGeneralManager profile={profile} mode="edit" />
|
||||||
<TabGeneralManager profile={profile} mode="edit" />
|
|
||||||
</Fieldset>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="poa">
|
<Tabs.Panel value="poa">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
|
||||||
</Fieldset>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="documents">
|
<Tabs.Panel value="documents">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
buildOnboardingSchema,
|
buildOnboardingSchema,
|
||||||
type CompanyStep,
|
type CompanyStep,
|
||||||
type FormData,
|
type FormData,
|
||||||
hasPoaDetails,
|
|
||||||
POA_DELEGATION_FILE_KEY,
|
POA_DELEGATION_FILE_KEY,
|
||||||
stepFields,
|
stepFields,
|
||||||
} from "./companyProfileForm/schema";
|
} from "./companyProfileForm/schema";
|
||||||
@@ -191,11 +190,7 @@ export default function CompanyProfileForm({
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
buildOnboardingSchema(
|
buildOnboardingSchema(identity?.passportRequired === true),
|
||||||
requirePoa,
|
|
||||||
verifiedIdentity,
|
|
||||||
identity?.passportRequired === true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
@@ -321,7 +316,6 @@ export default function CompanyProfileForm({
|
|||||||
// them and re-enables editing.
|
// them and re-enables editing.
|
||||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
|
||||||
|
|
||||||
// General Manager source. The company step's email/phone are seeded from
|
// 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
|
// 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 gmName = watch("generalManagerName");
|
||||||
const gmEmail = watch("generalManagerEmail");
|
const gmEmail = watch("generalManagerEmail");
|
||||||
const gmPhone = watch("generalManagerPhone");
|
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
|
// 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.
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
}, [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) => {
|
const toggleContactSameAsGm = (checked: boolean) => {
|
||||||
setContactSameAsGm(checked);
|
setContactSameAsGm(checked);
|
||||||
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// the same rule on save, so skipping it here only costs the customer a
|
||||||
// round-trip.
|
// 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 delegationRequired = requirePoa || poaProvided;
|
||||||
const delegationPresent =
|
const delegationPresent =
|
||||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
(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.");
|
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (step === "poa" && requirePoa && !identity?.poa.verified) {
|
||||||
step === "poa" &&
|
|
||||||
verifiedIdentity &&
|
|
||||||
requirePoa &&
|
|
||||||
!identity?.poa.verified
|
|
||||||
) {
|
|
||||||
setSaveError(
|
setSaveError(
|
||||||
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
"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."
|
? "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."}
|
: "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>
|
</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 && (
|
{identity && (
|
||||||
<FaydaVerifyPanel
|
<FaydaVerifyPanel
|
||||||
subject="poa"
|
subject="poa"
|
||||||
title="Power of Attorney"
|
title="Power of Attorney"
|
||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={identity.faydaRequired}
|
required={requirePoa}
|
||||||
onVerified={() => onIdentityChange?.()}
|
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
|
{/* The city is the one field the Fayda address claim does not
|
||||||
reliably decompose into, so it stays typed either way. */}
|
reliably decompose into, so it stays typed. */}
|
||||||
{verifiedIdentity && (
|
<TextInput
|
||||||
<TextInput
|
label="PoA Location"
|
||||||
label="PoA Location"
|
placeholder="City, Country"
|
||||||
placeholder="City, Country"
|
error={errors.poaLocation?.message}
|
||||||
error={errors.poaLocation?.message}
|
{...register("poaLocation")}
|
||||||
{...register("poaLocation")}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{poaDocumentSetting && (
|
{poaDocumentSetting && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -37,10 +37,8 @@ export function buildPayload(
|
|||||||
generalManagerName: data.generalManagerName,
|
generalManagerName: data.generalManagerName,
|
||||||
generalManagerEmail: data.generalManagerEmail,
|
generalManagerEmail: data.generalManagerEmail,
|
||||||
generalManagerPhone: data.generalManagerPhone,
|
generalManagerPhone: data.generalManagerPhone,
|
||||||
poaName: data.poaName || undefined,
|
// The representative's own details are written by their Fayda
|
||||||
poaPhone: data.poaPhone || undefined,
|
// verification, so the city is all the form has to send.
|
||||||
poaAddress: data.poaAddress || undefined,
|
|
||||||
poaEmail: data.poaEmail || undefined,
|
|
||||||
poaLocation: data.poaLocation || undefined,
|
poaLocation: data.poaLocation || undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -88,13 +86,7 @@ export function stepPayload(
|
|||||||
contactPersonPhone: d.contactPersonPhone,
|
contactPersonPhone: d.contactPersonPhone,
|
||||||
};
|
};
|
||||||
case "poa":
|
case "poa":
|
||||||
return {
|
return { poaLocation: d.poaLocation || undefined };
|
||||||
poaName: d.poaName || undefined,
|
|
||||||
poaPhone: d.poaPhone || undefined,
|
|
||||||
poaEmail: d.poaEmail || undefined,
|
|
||||||
poaLocation: d.poaLocation || undefined,
|
|
||||||
poaAddress: d.poaAddress || undefined,
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,58 +92,28 @@ export type FormData = z.infer<typeof onboardingSchema>;
|
|||||||
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
|
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
|
||||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
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
|
* The PoA's identifying fields are never typed — they come from the Fayda
|
||||||
* rather than optional. Everyone else keeps the optional PoA — but once they
|
* verification, whatever the company's nationality — so nothing here requires
|
||||||
* start filling it in, the identifying fields have to be complete (the
|
* them. A freight forwarder's mandatory PoA is gated on the verification
|
||||||
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
|
* itself, and its delegation letter alongside it, both in CompanyProfileForm
|
||||||
* since files live outside the form state).
|
* (files live outside form state).
|
||||||
|
*
|
||||||
|
* That leaves the owner's passport number as the only conditional field.
|
||||||
*/
|
*/
|
||||||
export function buildOnboardingSchema(
|
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. */
|
/** True for a foreign company: the owner's passport number is mandatory. */
|
||||||
passportRequired = false,
|
passportRequired = false,
|
||||||
) {
|
) {
|
||||||
const poaRequired = requirePoa && !faydaOwnedPoa;
|
if (!passportRequired) return onboardingSchema;
|
||||||
if (!poaRequired && !passportRequired) return onboardingSchema;
|
|
||||||
return onboardingSchema.superRefine((d, ctx) => {
|
return onboardingSchema.superRefine((d, ctx) => {
|
||||||
const required: [keyof FormData, string][] = [];
|
if (!d.ownerPassportNumber?.trim()) {
|
||||||
if (poaRequired) {
|
ctx.addIssue({
|
||||||
required.push(
|
code: z.ZodIssueCode.custom,
|
||||||
["poaName", "PoA name is required for freight forwarders"],
|
path: ["ownerPassportNumber"],
|
||||||
["poaEmail", "PoA email is required for freight forwarders"],
|
message: "The owner's passport number is required",
|
||||||
["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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -180,7 +150,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"contactPersonEmail",
|
"contactPersonEmail",
|
||||||
"contactPersonPhone",
|
"contactPersonPhone",
|
||||||
],
|
],
|
||||||
poa: [...POA_FIELDS],
|
poa: ["poaLocation"],
|
||||||
documents: [],
|
documents: [],
|
||||||
additional: [],
|
additional: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -27,10 +28,7 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
import {
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
paymentsService,
|
|
||||||
type PaymentMethod,
|
|
||||||
} from "@/services/payments.service";
|
|
||||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
@@ -72,21 +70,17 @@ export default function InvoiceDetailPage() {
|
|||||||
} = useQuery(api.invoices.get.queryOptions({ input: { id } }));
|
} = useQuery(api.invoices.get.queryOptions({ input: { id } }));
|
||||||
|
|
||||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
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
|
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
|
||||||
// one of the signed-in customer's own invoices (unlike the admin-facing
|
// one of the signed-in customer's own invoices (unlike the admin-facing
|
||||||
// /payments/initiate, which takes any invoiceId with no ownership check).
|
// /payments/initiate, which takes any invoiceId with no ownership check).
|
||||||
const payMutation = useMutation({
|
const pay = useInvoicePayment();
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -249,7 +243,7 @@ export default function InvoiceDetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
leftSection={<CreditCard size={16} />}
|
leftSection={<CreditCard size={16} />}
|
||||||
loading={payMutation.isPending}
|
loading={pay.processing}
|
||||||
onClick={handlePay}
|
onClick={handlePay}
|
||||||
styles={{
|
styles={{
|
||||||
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
||||||
@@ -376,23 +370,76 @@ export default function InvoiceDetailPage() {
|
|||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payModalOpen}
|
opened={payModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (!payMutation.isPending) {
|
if (!pay.processing) {
|
||||||
setPayModalOpen(false);
|
setPayModalOpen(false);
|
||||||
payMutation.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
amountLabel={formatCurrency(amountDue, invoice.currency)}
|
amountLabel={formatCurrency(amountDue, invoice.currency)}
|
||||||
currency={invoice.currency}
|
currency={invoice.currency}
|
||||||
processing={payMutation.isPending}
|
processing={pay.processing}
|
||||||
error={
|
error={pay.error}
|
||||||
payMutation.isError
|
otp={pay.otp}
|
||||||
? payMutation.error instanceof Error
|
onConfirm={(method, payerAccount) =>
|
||||||
? payMutation.error.message
|
pay.pay(id, method, payerAccount)
|
||||||
: "Could not start payment. Please try again."
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
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>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
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 type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||||
@@ -41,6 +37,7 @@ import { StatusHero } from "./components/StatusHero";
|
|||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
|
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||||
|
|
||||||
export function ReadonlyBookingView({
|
export function ReadonlyBookingView({
|
||||||
booking,
|
booking,
|
||||||
@@ -53,7 +50,6 @@ export function ReadonlyBookingView({
|
|||||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||||
useScrollToHash();
|
useScrollToHash();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
|
||||||
const { viewer } = useFileViewer();
|
const { viewer } = useFileViewer();
|
||||||
|
|
||||||
// Re-book opens the New Shipment Booking form for the same contract, not the
|
// Re-book opens the New Shipment Booking form for the same contract, not the
|
||||||
@@ -63,44 +59,11 @@ export function ReadonlyBookingView({
|
|||||||
: "/contracts/new";
|
: "/contracts/new";
|
||||||
const onRebook = () => navigate(rebookTo);
|
const onRebook = () => navigate(rebookTo);
|
||||||
|
|
||||||
// Billing is invoice-centric — resolve the booking's currently payable
|
// Billing is invoice-centric — the shared hook resolves the booking's
|
||||||
// invoice (same query/key BookingPaymentPanel uses, so this shares its
|
// currently payable invoice (same query/key BookingPaymentPanel uses, so it
|
||||||
// cache) and pay it through the ownership-checked portal route.
|
// shares that cache), charges it through the ownership-checked portal route,
|
||||||
const { data: bookingInvoices = [] } = useQuery({
|
// and handles redirect vs CAC Bank OTP.
|
||||||
queryKey: ["booking-invoices", booking.id],
|
const pay = useBookingPayment(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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pricing = booking.pricingBreakdown;
|
const pricing = booking.pricingBreakdown;
|
||||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||||
@@ -171,7 +134,7 @@ export function ReadonlyBookingView({
|
|||||||
green
|
green
|
||||||
icon={<CreditCard size={16} />}
|
icon={<CreditCard size={16} />}
|
||||||
label="Pay now"
|
label="Pay now"
|
||||||
onClick={() => setPayModalOpen(true)}
|
onClick={pay.open}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -276,8 +239,8 @@ export function ReadonlyBookingView({
|
|||||||
<BookingPaymentPanel
|
<BookingPaymentPanel
|
||||||
booking={booking}
|
booking={booking}
|
||||||
pricing={pricing}
|
pricing={pricing}
|
||||||
onPay={() => setPayModalOpen(true)}
|
onPay={pay.open}
|
||||||
paying={payMutation.isPending}
|
paying={pay.processing}
|
||||||
showCountdown={showCountdown}
|
showCountdown={showCountdown}
|
||||||
/>
|
/>
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
@@ -327,24 +290,14 @@ export function ReadonlyBookingView({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payModalOpen}
|
opened={pay.modalOpen}
|
||||||
onClose={() => {
|
onClose={pay.close}
|
||||||
if (!payMutation.isPending) {
|
|
||||||
setPayModalOpen(false);
|
|
||||||
payMutation.reset();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||||
processing={payMutation.isPending}
|
processing={pay.processing}
|
||||||
error={
|
error={pay.error}
|
||||||
payMutation.isError
|
otp={pay.otp}
|
||||||
? payMutation.error instanceof Error
|
onConfirm={pay.confirm}
|
||||||
? payMutation.error.message
|
|
||||||
: "Could not start payment. Please try again."
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
|
||||||
/>
|
/>
|
||||||
{viewer}
|
{viewer}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucid
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { client } from "@/utils/api";
|
import { client } from "@/utils/api";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
|
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
|
||||||
|
|
||||||
interface BulkTruckUploadModalProps {
|
interface BulkTruckUploadModalProps {
|
||||||
@@ -32,7 +33,7 @@ export function BulkTruckUploadModal({
|
|||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: async () => {
|
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,
|
trucks: parsed,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
|
import {
|
||||||
import { Check, ShieldCheck } from "lucide-react";
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
|
||||||
import type { PaymentMethod } from "@/services/payments.service";
|
import type { PaymentMethod } from "@/services/payments.service";
|
||||||
|
|
||||||
interface ProviderOption {
|
interface ProviderOption {
|
||||||
method: PaymentMethod;
|
method: PaymentMethod;
|
||||||
label: string;
|
label: string;
|
||||||
description: 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 this provider settles in. */
|
||||||
currencies: string[];
|
currencies: string[];
|
||||||
accent: 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[] = [
|
const PROVIDERS: ProviderOption[] = [
|
||||||
{
|
{
|
||||||
method: "TELEBIRR",
|
method: "TELEBIRR",
|
||||||
@@ -32,8 +44,26 @@ const PROVIDERS: ProviderOption[] = [
|
|||||||
currencies: ["USD"],
|
currencies: ["USD"],
|
||||||
accent: "#2E5B96",
|
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,
|
* Pick the provider that settles in the booking's currency. USD → Waafi,
|
||||||
* ETB → Telebirr. Falls back to the first provider when unknown.
|
* ETB → Telebirr. Falls back to the first provider when unknown.
|
||||||
@@ -91,13 +121,27 @@ function ProviderRow({
|
|||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{option.logo ? (
|
||||||
src={option.logo}
|
<Image
|
||||||
alt={`${option.label} logo`}
|
src={option.logo}
|
||||||
w={52}
|
alt={`${option.label} logo`}
|
||||||
h={52}
|
w={52}
|
||||||
fit="cover"
|
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>
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
||||||
@@ -135,6 +179,7 @@ export function PaymentMethodModal({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
processing,
|
processing,
|
||||||
error,
|
error,
|
||||||
|
otp,
|
||||||
}: {
|
}: {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -142,12 +187,19 @@ export function PaymentMethodModal({
|
|||||||
amountLabel?: string;
|
amountLabel?: string;
|
||||||
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
||||||
currency?: string | null;
|
currency?: string | null;
|
||||||
onConfirm: (method: PaymentMethod) => void;
|
onConfirm: (method: PaymentMethod, payerAccount?: string) => void;
|
||||||
processing?: boolean;
|
processing?: boolean;
|
||||||
error?: string | null;
|
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 [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.
|
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,6 +208,89 @@ export function PaymentMethodModal({
|
|||||||
}
|
}
|
||||||
}, [providers, method]);
|
}, [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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
@@ -215,6 +350,22 @@ export function PaymentMethodModal({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -228,7 +379,9 @@ export function PaymentMethodModal({
|
|||||||
<Group gap={6} align="center" justify="center" mb={12}>
|
<Group gap={6} align="center" justify="center" mb={12}>
|
||||||
<ShieldCheck size={14} color="#0A8A5F" />
|
<ShieldCheck size={14} color="#0A8A5F" />
|
||||||
<Text fz="11.5px" c="#7A8794">
|
<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>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -248,15 +401,21 @@ export function PaymentMethodModal({
|
|||||||
<Button
|
<Button
|
||||||
radius={12}
|
radius={12}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
disabled={processing}
|
disabled={processing || !canSubmit}
|
||||||
loading={processing}
|
loading={processing}
|
||||||
onClick={() => onConfirm(method)}
|
onClick={() =>
|
||||||
|
onConfirm(method, needsMobile ? mobile.trim() : undefined)
|
||||||
|
}
|
||||||
styles={{
|
styles={{
|
||||||
root: { height: 48, flex: 1 },
|
root: { height: 48, flex: 1 },
|
||||||
label: { fontSize: 14, fontWeight: 800 },
|
label: { fontSize: 14, fontWeight: 800 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{processing ? "Redirecting…" : "Continue to payment"}
|
{processing
|
||||||
|
? needsMobile
|
||||||
|
? "Sending OTP…"
|
||||||
|
: "Redirecting…"
|
||||||
|
: "Continue to payment"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
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 { CreditCard, Download, FileText, Receipt } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
import {
|
import {
|
||||||
warehouseInvoicesService,
|
warehouseInvoicesService,
|
||||||
type PortalWarehouseInvoice,
|
type PortalWarehouseInvoice,
|
||||||
@@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
|
|
||||||
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
||||||
|
|
||||||
const payMutation = useMutation({
|
// Warehouse fees are charged through the warehouse route, but they are the
|
||||||
mutationFn: (method: PaymentMethod) => {
|
// same central invoices — so redirect vs CAC Bank OTP is the shared flow.
|
||||||
if (!payInvoice) throw new Error("No invoice selected for payment.");
|
const pay = useInvoicePayment((invoiceId, method, payerAccount) =>
|
||||||
return warehouseInvoicesService.payOnline(payInvoice.id, {
|
warehouseInvoicesService.payOnline(invoiceId, {
|
||||||
method,
|
method,
|
||||||
platform: "web",
|
platform: "web",
|
||||||
});
|
payerAccount,
|
||||||
},
|
}),
|
||||||
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;
|
|
||||||
|
|
||||||
const closePayModal = () => {
|
const closePayModal = () => {
|
||||||
if (!payMutation.isPending) {
|
if (!pay.processing) {
|
||||||
setPayInvoice(null);
|
setPayInvoice(null);
|
||||||
payMutation.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
||||||
}
|
}
|
||||||
currency={payInvoice?.currency}
|
currency={payInvoice?.currency}
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
onConfirm={(method, payerAccount) =>
|
||||||
processing={payMutation.isPending}
|
payInvoice && pay.pay(payInvoice.id, method, payerAccount)
|
||||||
error={payError}
|
}
|
||||||
|
processing={pay.processing}
|
||||||
|
error={pay.error}
|
||||||
|
otp={pay.otp}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function PayNowButton({
|
|||||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||||
processing={pay.processing}
|
processing={pay.processing}
|
||||||
error={pay.error}
|
error={pay.error}
|
||||||
|
otp={pay.otp}
|
||||||
onConfirm={pay.confirm}
|
onConfirm={pay.confirm}
|
||||||
/>
|
/>
|
||||||
</ModalSafeWrapper>
|
</ModalSafeWrapper>
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
import {
|
import { type PaymentMethod } from "@/services/payments.service";
|
||||||
paymentsService,
|
|
||||||
type PaymentMethod,
|
|
||||||
} from "@/services/payments.service";
|
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared payment flow for a single booking: opens the method modal, fires
|
* Shared payment flow for a single booking: opens the method modal, fires
|
||||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
||||||
* invoice, and redirects the browser to the provider (or the fallback
|
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
||||||
* checkout page). Reused by the booking detail page, the booking list, and
|
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
||||||
* the home page so "Pay now" behaves identically everywhere.
|
* the booking detail page, the booking list, and the home page so "Pay now"
|
||||||
|
* behaves identically everywhere.
|
||||||
*/
|
*/
|
||||||
export function useBookingPayment(bookingId: string) {
|
export function useBookingPayment(bookingId: string) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [noInvoice, setNoInvoice] = useState(false);
|
||||||
|
|
||||||
const { data: invoices = [] } = useQuery({
|
const { data: invoices = [] } = useQuery({
|
||||||
queryKey: ["booking-invoices", bookingId],
|
queryKey: ["booking-invoices", bookingId],
|
||||||
@@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) {
|
|||||||
});
|
});
|
||||||
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
||||||
|
|
||||||
const mutation = useMutation({
|
const flow = useInvoicePayment();
|
||||||
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 open = () => setModalOpen(true);
|
const open = () => setModalOpen(true);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (!mutation.isPending) {
|
if (!flow.processing) {
|
||||||
setModalOpen(false);
|
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 {
|
return {
|
||||||
modalOpen,
|
modalOpen,
|
||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
processing: mutation.isPending,
|
processing: flow.processing,
|
||||||
error,
|
error: noInvoice
|
||||||
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
? "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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,20 +39,15 @@ import {
|
|||||||
type LicenseFile,
|
type LicenseFile,
|
||||||
type LicenseFileStatus,
|
type LicenseFileStatus,
|
||||||
} from "@/services/companies.service";
|
} from "@/services/companies.service";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
|
||||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
import { verifaydaService } from "@/services/verifayda.service";
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
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({
|
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(),
|
poaLocation: z.string().optional(),
|
||||||
poaAddress: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
@@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({
|
|||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo(
|
||||||
return {
|
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
|
||||||
poaName: profile.poaName ?? "",
|
[profile],
|
||||||
poaEmail: profile.poaEmail ?? "",
|
);
|
||||||
poaPhone: profile.poaPhone ?? "",
|
|
||||||
poaLocation: profile.poaLocation ?? "",
|
|
||||||
poaAddress: profile.poaAddress ?? "",
|
|
||||||
};
|
|
||||||
}, [profile]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
watch,
|
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({
|
|||||||
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
|
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
|
||||||
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
|
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
|
||||||
|
|
||||||
// The letter is staged locally, not uploaded on pick. Uploading immediately
|
// The letter is staged locally, not uploaded on pick: the paper is the one
|
||||||
// would open a change request, which locks the whole settings page (see
|
// thing here that still goes to a reviewer, so picking it must not open a
|
||||||
// SettingsPage's `locked` fieldset) before the text fields could be saved.
|
// change request before the customer has committed to the save. Save submits
|
||||||
// Save submits the file and the fields together, into one change request.
|
// the file and the fields together.
|
||||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||||
const [saveBlocked, setSaveBlocked] = useState(false);
|
const [saveBlocked, setSaveBlocked] = useState(false);
|
||||||
@@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({
|
|||||||
const requirePoa = profile.companyProfiles.some(
|
const requirePoa = profile.companyProfiles.some(
|
||||||
(p) => p.type === "freight_forwarder",
|
(p) => p.type === "freight_forwarder",
|
||||||
);
|
);
|
||||||
// An Ethiopian company does not type its representative's details — they
|
// No company types its representative's details — they come from the Fayda
|
||||||
// come from the Fayda verification. A foreign company keeps the typed form:
|
// verification whatever the nationality, since a representative acts for the
|
||||||
// its representative may hold no Fayda ID.
|
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||||
|
// has been verified.
|
||||||
const identity = profile.identity;
|
const identity = profile.identity;
|
||||||
const verifiedIdentity = identity?.faydaRequired === true;
|
const poaProvided = identity?.poa.verified ?? false;
|
||||||
|
|
||||||
const poaValues = watch([
|
|
||||||
"poaName",
|
|
||||||
"poaEmail",
|
|
||||||
"poaPhone",
|
|
||||||
"poaLocation",
|
|
||||||
"poaAddress",
|
|
||||||
]);
|
|
||||||
const poaProvided = verifiedIdentity
|
|
||||||
? (identity?.poa.verified ?? false)
|
|
||||||
: poaValues.some((v) => v?.trim());
|
|
||||||
const letterRequired = requirePoa || poaProvided;
|
const letterRequired = requirePoa || poaProvided;
|
||||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||||
|
|
||||||
@@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: FormData) => {
|
mutationFn: async (data: FormData) => {
|
||||||
// Every identity field except the city is written by the verification, so
|
// Every identity field except the city is written by the verification, so
|
||||||
// an Ethiopian company only ever saves the paper and the location here.
|
// only the paper and the location are ever saved here.
|
||||||
const fields = verifiedIdentity
|
const fields = { poaLocation: data.poaLocation || undefined };
|
||||||
? { poaLocation: data.poaLocation || undefined }
|
|
||||||
: {
|
|
||||||
poaName: data.poaName || undefined,
|
|
||||||
poaPhone: data.poaPhone || undefined,
|
|
||||||
poaEmail: data.poaEmail || undefined,
|
|
||||||
poaLocation: data.poaLocation || undefined,
|
|
||||||
poaAddress: data.poaAddress || undefined,
|
|
||||||
};
|
|
||||||
// A fresh upload already stages the removal of every paper on file, so
|
// A fresh upload already stages the removal of every paper on file, so
|
||||||
// the explicit removals only need applying when no replacement was
|
// the explicit removals only need applying when no replacement was
|
||||||
// picked. Saving the details after it means the API sees the new paper.
|
// picked. Saving the details after it means the API sees the new paper.
|
||||||
@@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({
|
|||||||
subject="poa"
|
subject="poa"
|
||||||
title="Power of Attorney"
|
title="Power of Attorney"
|
||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={identity.faydaRequired}
|
required={requirePoa}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
pendingReview={Boolean(
|
|
||||||
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
|
||||||
?.faydaIdentity?.poaFaydaSub,
|
|
||||||
)}
|
|
||||||
onVerified={() => {
|
onVerified={() => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
@@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Name, email, phone and address are written by the Fayda
|
{/* Name, email, phone and address are all written by the Fayda
|
||||||
verification for an Ethiopian company, so only the city — which
|
verification, so only the city — which the address claim does
|
||||||
the address claim does not reliably decompose into — is typed. */}
|
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -342,16 +278,6 @@ export default function TabPowerOfAttorney({
|
|||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
{!verifiedIdentity && (
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="PoA Address"
|
|
||||||
placeholder="Full Address"
|
|
||||||
error={errors.poaAddress?.message}
|
|
||||||
{...register("poaAddress")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
)}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({
|
|||||||
</Stack>
|
</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">
|
<Group gap={6} c="edr-amber-text">
|
||||||
<Clock size={13} />
|
<Clock size={13} />
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
@@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
{mode === "edit" &&
|
{mode === "edit" &&
|
||||||
verifiedIdentity &&
|
|
||||||
identity?.poa.verified &&
|
identity?.poa.verified &&
|
||||||
!requirePoa && (
|
!requirePoa && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -94,10 +94,12 @@ export interface CompanyInfoResponse {
|
|||||||
company: CompanyResponse;
|
company: CompanyResponse;
|
||||||
/**
|
/**
|
||||||
* Open profile-edit review, if any. `pending` locks the settings page + new
|
* 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?: {
|
review?: {
|
||||||
status: "pending" | "rejected";
|
status: "pending" | "rejected" | "changes_requested";
|
||||||
note: string | null;
|
note: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,7 @@ export interface CompanyInfoResponse {
|
|||||||
export interface ChangeRequestResponse {
|
export interface ChangeRequestResponse {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
status: "pending" | "approved" | "rejected";
|
status: "pending" | "approved" | "rejected" | "changes_requested";
|
||||||
snapshot: Record<string, any>;
|
snapshot: Record<string, any>;
|
||||||
documentFileIds: string[];
|
documentFileIds: string[];
|
||||||
note: string | null;
|
note: string | null;
|
||||||
|
|||||||
@@ -73,4 +73,10 @@ export const invoicesService = {
|
|||||||
});
|
});
|
||||||
return data.data ?? data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export type PaymentMethod =
|
|||||||
| "WAAFI"
|
| "WAAFI"
|
||||||
| "CARD"
|
| "CARD"
|
||||||
| "DMONEY"
|
| "DMONEY"
|
||||||
| "CAC_BANK";
|
| "CAC_BANK"
|
||||||
|
| "CBE_BILL";
|
||||||
|
|
||||||
export type PaymentPlatform = "web" | "mobile";
|
export type PaymentPlatform = "web" | "mobile";
|
||||||
|
|
||||||
@@ -26,13 +27,17 @@ export interface InitiatePaymentPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientAction {
|
export interface ClientAction {
|
||||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||||
url?: string;
|
url?: string;
|
||||||
appId?: string;
|
appId?: string;
|
||||||
receiveCode?: string;
|
receiveCode?: string;
|
||||||
shortCode?: string;
|
shortCode?: string;
|
||||||
providerOrderId?: string;
|
providerOrderId?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
/** SHOW_BILL_REFERENCE (CBE bill payment) */
|
||||||
|
billReference?: string;
|
||||||
|
instructions?: string;
|
||||||
|
expiresAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InitiateResponse {
|
export interface InitiateResponse {
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ export interface ProfileResponse {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
/**
|
/**
|
||||||
* Open profile-edit review. `pending` → the settings page is read-only until an
|
* 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
|
* admin decides; `rejected`/`changes_requested` → the note explains why and
|
||||||
* declined values so the customer can amend & resubmit.
|
* 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;
|
reviewNote?: string | null;
|
||||||
pendingChanges?: Record<string, any> | null;
|
pendingChanges?: Record<string, any> | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -147,6 +147,7 @@ enum PaymentMethodType {
|
|||||||
WAAFI
|
WAAFI
|
||||||
DMONEY
|
DMONEY
|
||||||
CAC_BANK
|
CAC_BANK
|
||||||
|
CBE_BILL
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user