mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +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
|
||||
|
||||
- name: Resolve env file path for ${{ matrix.service }}
|
||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||
if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
|
||||
run: |
|
||||
case "${{ matrix.service }}" in
|
||||
freight-api) echo "SERVICE_ENV_FILE=apps/edr-freight-api/.env" >> "$GITHUB_ENV" ;;
|
||||
passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;;
|
||||
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
|
||||
esac
|
||||
|
||||
- name: Build migration image for ${{ matrix.service }}
|
||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||
if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build \
|
||||
@@ -163,7 +164,7 @@ jobs:
|
||||
.
|
||||
|
||||
- name: Run migrations for ${{ matrix.service }}
|
||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||
if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"
|
||||
|
||||
@@ -154,13 +154,32 @@ no timeout and will wait forever.
|
||||
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
|
||||
already come from it.
|
||||
|
||||
- `migrationsRun: true` — **migrations run automatically on API boot**, with
|
||||
`migrationsTransactionMode: 'each'`.
|
||||
- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
|
||||
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
|
||||
migration`), with `migrationsTransactionMode: 'each'`.
|
||||
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
|
||||
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
|
||||
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
|
||||
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
|
||||
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
|
||||
migrations yourself before `docker compose up freight-api`, e.g.
|
||||
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
|
||||
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
|
||||
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
|
||||
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
|
||||
It silently applies zero freight migrations while exiting 0.
|
||||
- Consequences you must design for:
|
||||
- Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent
|
||||
data migration can execute twice. Keep one instance.
|
||||
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
|
||||
code reads, apply it to the dev database yourself (idempotently) or fully restart.
|
||||
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
|
||||
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
|
||||
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
|
||||
`forFeature()` registrations), but the standalone migration `DataSource`
|
||||
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
|
||||
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
|
||||
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
|
||||
for this to break again** — diff the package's entity classes against `iamEntities`
|
||||
when bumping it.
|
||||
- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
|
||||
more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
|
||||
adding one, check the filename prefix is unused *and* higher than the newest recorded row.
|
||||
|
||||
@@ -29,7 +29,12 @@ COPY --from=builder /app/ .
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
||||
|
||||
FROM deployer AS migration
|
||||
WORKDIR /deploy
|
||||
CMD ["node", "dist/scripts/migrate.js"]
|
||||
|
||||
FROM base AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||
"backfill:missing-unload-inventory": "ts-node -r tsconfig-paths/register src/scripts/backfill-missing-unload-inventory.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
|
||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||
@@ -36,6 +37,7 @@
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||
"migration:run": "node dist/scripts/migrate.js",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -57,8 +59,8 @@
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.12.tgz",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { DataSourceOptions } from "typeorm";
|
||||
import { join, dirname } from "path";
|
||||
import {
|
||||
DefaultPosition,
|
||||
@@ -44,8 +45,30 @@ import {
|
||||
NotificationTemplate,
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
|
||||
import { UnitSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-setting.entity";
|
||||
import { UnitDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-detail.entity";
|
||||
import { OrganizationDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-detail.entity";
|
||||
import { UnitCluster } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-cluster.entity";
|
||||
import { Location } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location.entity";
|
||||
import { LocationType } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location-type.entity";
|
||||
import { DelegationTerminationReason } from "@tria-plc/iamapi-common/entities/iam/organization-structure/delegation-termination-reason.entity";
|
||||
import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee-position-active-period.entity";
|
||||
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
|
||||
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
|
||||
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
|
||||
|
||||
const iamEntities = [
|
||||
UnitSetting,
|
||||
UnitDetail,
|
||||
OrganizationDetail,
|
||||
UnitCluster,
|
||||
Location,
|
||||
LocationType,
|
||||
DelegationTerminationReason,
|
||||
EmployeePositionActivePeriod,
|
||||
UnitConfiguration,
|
||||
Site,
|
||||
SiteSetting,
|
||||
DefaultPosition,
|
||||
DefaultUnit,
|
||||
EmployeePosition,
|
||||
@@ -95,7 +118,7 @@ const iamMigrationsGlob = join(
|
||||
);
|
||||
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
||||
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
export function buildDataSourceOptions(): DataSourceOptions {
|
||||
return {
|
||||
type: "postgres",
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
@@ -111,17 +134,19 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
// The search_path is instead applied per-connection via a pool `connect`
|
||||
// handler in app.module.ts (see setPoolSearchPath).
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
autoLoadEntities: true,
|
||||
migrations: [
|
||||
// IAM schema + tables must be created before freight migrations
|
||||
iamMigrationsGlob,
|
||||
freightMigrationsGlob,
|
||||
],
|
||||
migrationsRun: true,
|
||||
migrationsTransactionMode: "each",
|
||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
||||
synchronize: false,
|
||||
logging:
|
||||
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => ({
|
||||
...buildDataSourceOptions(),
|
||||
autoLoadEntities: true,
|
||||
migrationsRun: false,
|
||||
}));
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
// apps/edr-freight-api/src/data-source.ts
|
||||
import 'dotenv/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
||||
import "dotenv/config";
|
||||
import { DataSource } from "typeorm";
|
||||
import { buildDataSourceOptions } from "./config/database.config";
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
port: Number(process.env.DB_PORT ?? 5433),
|
||||
username: process.env.DB_USER ?? 'postgres',
|
||||
password: process.env.DB_PASSWORD ?? '',
|
||||
database: process.env.DB_NAME ?? 'edr_freight',
|
||||
schema: 'freight', // default schema for entities without an explicit schema
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||
synchronize: false,
|
||||
logging: process.env.TYPEORM_LOGGING === 'true',
|
||||
});
|
||||
export const AppDataSource = new DataSource(buildDataSourceOptions());
|
||||
|
||||
// Optional: call ensurePostgresSchemas before initializing
|
||||
// But you can also run it separately.
|
||||
export default AppDataSource;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
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 { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
@@ -55,6 +55,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/**
|
||||
* Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE
|
||||
* bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter,
|
||||
* so "cancelled" must not stand in for "already paid" or "refunded".
|
||||
*/
|
||||
function closedInvoiceReason(status: Freight.InvoiceStatus): string {
|
||||
switch (status) {
|
||||
case Freight.InvoiceStatus.Paid:
|
||||
return "ALREADY_PAID";
|
||||
case Freight.InvoiceStatus.Refunded:
|
||||
return "REFUNDED";
|
||||
case Freight.InvoiceStatus.Cancelled:
|
||||
return "CANCELLED";
|
||||
case Freight.InvoiceStatus.Expired:
|
||||
return "EXPIRED";
|
||||
// Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable".
|
||||
default:
|
||||
return "NOT_PAYABLE";
|
||||
}
|
||||
}
|
||||
|
||||
/** A single line to bill on a generated invoice. */
|
||||
export interface InvoiceLineInput {
|
||||
chargeType: string;
|
||||
@@ -352,6 +373,34 @@ export class BillingService {
|
||||
return this.payInvoice(id, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the CAC Bank OTP for one of the customer's own invoices
|
||||
* (ownership-checked). Settlement of the invoice happens inside the payment
|
||||
* service when the OTP succeeds.
|
||||
*/
|
||||
async confirmInvoiceOtpForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
otp: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.confirmInvoiceOtp(id, otp);
|
||||
}
|
||||
|
||||
/** OTP confirmation by invoice id — the intent is the one stamped at initiate. */
|
||||
async confirmInvoiceOtp(
|
||||
invoiceId: string,
|
||||
otp: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
const invoice = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findOne({ where: { id: invoiceId } });
|
||||
if (!invoice?.paymentId) {
|
||||
throw new NotFoundException("No payment to confirm for this invoice");
|
||||
}
|
||||
return this.payment.confirmOtp(invoice.paymentId, otp);
|
||||
}
|
||||
|
||||
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||
async documentForUser(
|
||||
id: string,
|
||||
@@ -1006,6 +1055,7 @@ export class BillingService {
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(
|
||||
@@ -1035,6 +1085,17 @@ export class BillingService {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
|
||||
// required up front (the payment service rejects it otherwise, as a 502 here).
|
||||
if (
|
||||
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
|
||||
!opts.payerAccount?.trim()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"payerAccount (mobile number) is required for CAC Bank",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: invoice.sourceId,
|
||||
source: invoice.source,
|
||||
@@ -1051,6 +1112,10 @@ export class BillingService {
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
payerAccount: opts.payerAccount,
|
||||
// CBE_BILL: payer identity + the invoice's own due date as the bill expiry
|
||||
// (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4).
|
||||
payerName: invoice.company?.name,
|
||||
expiresAt: invoice.dueAt?.toISOString(),
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
@@ -1062,7 +1127,13 @@ export class BillingService {
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept commented for local demos only.
|
||||
if (!result.immediateSuccess) {
|
||||
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
||||
// code — so the demo shortcut must never fire for it.
|
||||
if (
|
||||
!result.immediateSuccess &&
|
||||
result.response.clientAction?.type !== "COLLECT_OTP" &&
|
||||
opts.method !== "CBE_BILL"
|
||||
) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
@@ -1107,4 +1178,67 @@ export class BillingService {
|
||||
paidAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
|
||||
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
|
||||
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
|
||||
*/
|
||||
async billQuery(referenceId: string): Promise<{
|
||||
stillPayable: boolean;
|
||||
payerName?: string | null;
|
||||
currentAmountMinor?: number | null;
|
||||
currency?: string | null;
|
||||
reason?: string | null;
|
||||
paymentReason?: string | null;
|
||||
}> {
|
||||
const repo = this.dataSource.getRepository(Invoice);
|
||||
const open = await repo.findOne({
|
||||
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
|
||||
if (open) {
|
||||
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
|
||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||
return {
|
||||
stillPayable: balance > 0 && !expired,
|
||||
payerName: open.company?.name ?? null,
|
||||
currentAmountMinor: balance,
|
||||
currency: open.currency,
|
||||
// CBE shows this beside the amount on the confirmation screen — the invoice number
|
||||
// the payer is holding, not our internal reference.
|
||||
paymentReason: `Freight invoice ${open.invoiceNumber}`,
|
||||
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
|
||||
// expired, and that is what the payer at the CBE counter must be told.
|
||||
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
|
||||
};
|
||||
}
|
||||
|
||||
const latest = await repo.findOne({
|
||||
where: { sourceId: referenceId },
|
||||
relations: { company: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
// A bill reference whose invoice no longer exists at all — a data problem, not a
|
||||
// cancellation the payer did anything to cause.
|
||||
if (!latest) {
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: null,
|
||||
currentAmountMinor: null,
|
||||
currency: null,
|
||||
reason: "NOT_FOUND",
|
||||
};
|
||||
}
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: latest.company?.name ?? null,
|
||||
currentAmountMinor: Math.round(Number(latest.totalAmount)),
|
||||
currency: latest.currency,
|
||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||
reason: closedInvoiceReason(latest.status),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */
|
||||
export class ConfirmOtpDto {
|
||||
@ApiProperty({ description: "One-time password SMSed by the bank." })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otp!: string;
|
||||
}
|
||||
|
||||
/** Gateway options for paying an invoice from the customer portal. */
|
||||
export class PayInvoiceDto {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { sendPdf } from "./billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
|
||||
/**
|
||||
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||
@@ -96,4 +96,20 @@ export class PortalBillingController {
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("my-invoices/:id/confirm")
|
||||
@ApiOperation({
|
||||
summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices",
|
||||
})
|
||||
confirmOtp(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: ConfirmOtpDto,
|
||||
) {
|
||||
return this.billingService.confirmInvoiceOtpForUser(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.otp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,26 @@ export class BookingsController {
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/carriage-acceptance-sheet')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
|
||||
})
|
||||
async carriageAcceptanceSheet(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks')
|
||||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||
async listCustomerTrucks(
|
||||
|
||||
@@ -70,6 +70,29 @@ export interface PaginatedBookings {
|
||||
};
|
||||
}
|
||||
|
||||
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
|
||||
interface CarriageAcceptanceWagonRow {
|
||||
sequenceNo: number;
|
||||
wagonType: string | null;
|
||||
wagonNumber: string | null;
|
||||
tareWeightTons: string | null;
|
||||
equatedLength: string | null;
|
||||
loadCapacityTons: string | null;
|
||||
allocatedWeightTons: string | null;
|
||||
trainNumber: string | null;
|
||||
departureAt: Date | null;
|
||||
marshalledAt: string | null;
|
||||
arrivalAt: string | null;
|
||||
containerNumbers: string | null;
|
||||
sealNumbers: string | null;
|
||||
}
|
||||
|
||||
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
||||
interface CarriageAcceptanceReceivedRow {
|
||||
allocatedWeightTons: string | null;
|
||||
containerNumbers: string | null;
|
||||
}
|
||||
|
||||
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||
const NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
@@ -208,6 +231,284 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
||||
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
|
||||
* the wagons are allocated before marshalling (import), so it is only available
|
||||
* once the booking has wagon allocations.
|
||||
*/
|
||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
COALESCE(wt.code, wt.name) AS "wagonType",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
wt.tare_weight_tons AS "tareWeightTons",
|
||||
tsw.length_meters AS "equatedLength",
|
||||
tsw.capacity_tons AS "loadCapacityTons",
|
||||
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||
s.train_number AS "trainNumber",
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "marshalledAt",
|
||||
sd.label AS "arrivalAt",
|
||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
LEFT JOIN freight.train_schedules s
|
||||
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
);
|
||||
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
|
||||
// takes custody of the cargo when it receives it, and the customer is handed
|
||||
// this sheet then — before the booking is put on a train. So a received
|
||||
// export booking gets its sheet off the received cargo, wagon columns blank
|
||||
// until the consist exists. Import keeps the allocation gate: nothing is
|
||||
// accepted from the customer before the wagons carry it.
|
||||
//
|
||||
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
|
||||
// and never appears on this sheet — it is only the signal that EDR has taken
|
||||
// the cargo, which is what the customer's sheet attests to.
|
||||
const pendingWagons = wagons.length === 0;
|
||||
if (pendingWagons) {
|
||||
const receivedLines: CarriageAcceptanceReceivedRow[] =
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
? await this.dataSource.query(
|
||||
`SELECT inv.weight AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.containers c
|
||||
ON c.id = inv.container_id AND c.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
|
||||
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
|
||||
ORDER BY inv.created_at`,
|
||||
[bookingId],
|
||||
)
|
||||
: [];
|
||||
if (receivedLines.length === 0) {
|
||||
throw new BadRequestException(
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet'
|
||||
: 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation',
|
||||
);
|
||||
}
|
||||
wagons = receivedLines.map((row, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
wagonType: null,
|
||||
wagonNumber: null,
|
||||
tareWeightTons: null,
|
||||
equatedLength: null,
|
||||
loadCapacityTons: null,
|
||||
allocatedWeightTons: row.allocatedWeightTons,
|
||||
trainNumber: null,
|
||||
departureAt: null,
|
||||
marshalledAt: null,
|
||||
arrivalAt: null,
|
||||
containerNumbers: row.containerNumbers,
|
||||
sealNumbers: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons });
|
||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||
label: 'carriage acceptance sheet',
|
||||
fallback: (prepared) => buildTabularFallbackPdf(prepared),
|
||||
});
|
||||
return {
|
||||
filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the booking amount across its wagons, proportional to allocated weight
|
||||
* (equal shares when no weights are recorded). The last row absorbs the rounding
|
||||
* remainder so the Price column always sums to the Total Amount on the sheet.
|
||||
*/
|
||||
private splitAmountAcrossWagons(total: number, weights: number[]): number[] {
|
||||
const sum = weights.reduce((acc, w) => acc + w, 0);
|
||||
const shares = weights.map((w) =>
|
||||
Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100,
|
||||
);
|
||||
const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100;
|
||||
shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100;
|
||||
return shares;
|
||||
}
|
||||
|
||||
private buildCarriageAcceptanceSheetHtml(
|
||||
booking: Booking,
|
||||
wagons: CarriageAcceptanceWagonRow[],
|
||||
{ pendingWagons }: { pendingWagons: boolean },
|
||||
): string {
|
||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||
const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits);
|
||||
const money = (v: number) =>
|
||||
v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
|
||||
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
|
||||
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
|
||||
const currency = booking.paymentCurrency ?? 'ETB';
|
||||
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
|
||||
const prices = this.splitAmountAcrossWagons(
|
||||
totalAmount,
|
||||
wagons.map((w) => Number(w.allocatedWeightTons) || 0),
|
||||
);
|
||||
const header = wagons[0];
|
||||
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
||||
|
||||
const totals = wagons.reduce(
|
||||
(acc, w) => ({
|
||||
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
||||
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
||||
load: acc.load + (Number(w.allocatedWeightTons) || 0),
|
||||
length: acc.length + (Number(w.equatedLength) || 0),
|
||||
}),
|
||||
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
||||
);
|
||||
// A wagon carrying no weight and no container is running empty under this booking.
|
||||
const fullWagons = wagons.filter(
|
||||
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
||||
).length;
|
||||
|
||||
const rows = wagons
|
||||
.map(
|
||||
(w, i) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td>${esc(w.wagonType)}</td>
|
||||
<td>${esc(w.wagonNumber)}</td>
|
||||
<td class="num">${num(w.tareWeightTons, 2)}</td>
|
||||
<td class="num">${num(w.equatedLength)}</td>
|
||||
<td class="num">${num(w.loadCapacityTons)}</td>
|
||||
<td>${esc(arrivalStation)}</td>
|
||||
<td>${esc(cargoName)}</td>
|
||||
<td>${esc(departureStation)}</td>
|
||||
<td>${esc(w.containerNumbers)}</td>
|
||||
<td>${esc(w.sealNumbers)}</td>
|
||||
<td class="num">${money(prices[i])}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Carriage Acceptance Sheet</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||||
.subtitle { font-size: 11px; color: #475569; margin-top: 4px; }
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tfoot td { background: #f8fafc; font-weight: 700; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Carriage Acceptance Sheet</h1>
|
||||
<div class="subtitle">Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Sheet No.
|
||||
<strong>CAS-${esc(booking.reference)}</strong>
|
||||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Marshalled at</span><strong>${esc(header.marshalledAt ?? departureStation)}</strong></div>
|
||||
<div class="tile"><span>Arrival at</span><strong>${esc(header.arrivalAt ?? arrivalStation)}</strong></div>
|
||||
<div class="tile"><span>Date and time</span><strong>${esc(sheetDate.toLocaleString('en-GB'))}</strong></div>
|
||||
<div class="tile"><span>Train No.</span><strong>${esc(header.trainNumber)}</strong></div>
|
||||
<div class="tile"><span>Customer</span><strong>${esc(booking.company?.name)}</strong></div>
|
||||
<div class="tile"><span>Cargo</span><strong>${esc(cargoName)}</strong></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">SN</th>
|
||||
<th>Type of Wagon</th>
|
||||
<th>Wagon No.</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Arrival Station</th>
|
||||
<th>Cargo Name</th>
|
||||
<th>Departure Station</th>
|
||||
<th>Container No.</th>
|
||||
<th>Seal No.</th>
|
||||
<th class="num">Price (${esc(currency)})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">${
|
||||
pendingWagons
|
||||
? `Received lines: ${wagons.length} — wagons pending marshalling`
|
||||
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
|
||||
}</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
${
|
||||
pendingWagons
|
||||
? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}.
|
||||
Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.`
|
||||
: `The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}.
|
||||
Wagon identity, container and seal numbers must be verified against the physical consist
|
||||
before the sheet is signed.`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Signed by — EDR operations / date</div>
|
||||
<div class="line">Signed by — customer or agent / date</div>
|
||||
<div class="line">Signed by — marshalling yard / date</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
/**
|
||||
* An intercity corridor is valid when both yards are Ethiopian and at least
|
||||
|
||||
@@ -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 { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||
import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
|
||||
@@ -685,6 +686,17 @@ export class CompaniesController {
|
||||
return requests.map((r) => new ChangeRequestResponseDto(r));
|
||||
}
|
||||
|
||||
@Get(":companyId/revisions")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Onboarding-phase edit history (version history)" })
|
||||
async listCompanyRevisions(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<CompanyRevisionResponseDto[]> {
|
||||
const revisions =
|
||||
await this.companiesService.listCompanyRevisions(companyId);
|
||||
return revisions.map((r) => new CompanyRevisionResponseDto(r));
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
@@ -719,6 +731,25 @@ export class CompaniesController {
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/request-changes")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
|
||||
})
|
||||
async requestChangeRequestChanges(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectChangeRequestDto,
|
||||
): Promise<ChangeRequestResponseDto> {
|
||||
const request = await this.companiesService.requestChangeRequestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post(":companyId/profiles")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
|
||||
@@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
|
||||
* come from the verified payload, not typed. Fayda's userinfo carries no
|
||||
* national ID number, so none is collected or derived here.
|
||||
*
|
||||
* - Ethiopian company: the owner (and its PoA, once named) is verified through
|
||||
* Fayda, and their details can't be edited afterwards.
|
||||
* Only the OWNER's credential varies by nationality:
|
||||
* - Ethiopian company: the owner is verified through Fayda.
|
||||
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
||||
* supplies a typed passport number — required on its own, whether or not the
|
||||
* owner also completes a (purely optional) Fayda verification.
|
||||
*
|
||||
* The PoA does not vary. A representative acts for the company inside Ethiopia
|
||||
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
|
||||
* is named, both nationalities must verify them, and their details come from
|
||||
* the verified payload rather than the form.
|
||||
*
|
||||
* The owner is NOT the general manager — GM is a separate, plain typed role
|
||||
* the portal offers a "same as owner" copy for, but it is never itself
|
||||
* Fayda-verified or gated on.
|
||||
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
},
|
||||
changeRequestRepo: {
|
||||
findPendingByCompanyId: jest.fn(async () => null),
|
||||
findLatestOpenByCompanyId: jest.fn(async () => null),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
@@ -114,6 +120,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "rev-1",
|
||||
...row,
|
||||
})),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
@@ -138,6 +151,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.revisionRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
@@ -229,9 +243,27 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("stages the change for review on an approved company", async () => {
|
||||
// Swapping the person who can act for a live company is exactly what the
|
||||
// backoffice review exists for, so it must not rewrite the row directly.
|
||||
it("stages an owner re-verification for review on an approved company", async () => {
|
||||
// The owner is the live company's identity proof, so re-verifying one is
|
||||
// exactly what the backoffice review exists for: it must not rewrite the
|
||||
// row directly.
|
||||
const { service, ctx, deps } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
|
||||
await service.completeIdentityVerification("user-1", {
|
||||
subject: "owner",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies a PoA verification live on an approved company", async () => {
|
||||
// The PoA is personnel the company names for itself — the delegation paper
|
||||
// is what a reviewer actually judges — so it does not go to review.
|
||||
const { service, ctx, deps } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
@@ -242,8 +274,8 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
|
||||
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
||||
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
||||
});
|
||||
|
||||
it("refuses to rename a verified person by hand", async () => {
|
||||
@@ -367,7 +399,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
|
||||
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: {
|
||||
ownerPassportNumber: "P1234567",
|
||||
...POA_VERIFIED,
|
||||
},
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("still requires a Fayda-verified PoA from a foreign company", async () => {
|
||||
// The owner's credential is nationality-specific; the representative's is
|
||||
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
|
||||
// typed foreign name is not a representative the platform can accept.
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
@@ -385,7 +439,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
||||
|
||||
@@ -15,9 +15,11 @@ import { Company } from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
@@ -29,6 +31,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
ExternalProfile,
|
||||
CompanyProfile,
|
||||
CompanyChangeRequest,
|
||||
CompanyRevision,
|
||||
Booking,
|
||||
]),
|
||||
HttpModule,
|
||||
@@ -49,6 +52,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
ExternalProfileRepository,
|
||||
CompanyProfileRepository,
|
||||
CompanyChangeRequestRepository,
|
||||
CompanyRevisionRepository,
|
||||
CompanyDashboardRepository,
|
||||
ETradeService,
|
||||
CompanyNotifierService,
|
||||
|
||||
@@ -81,6 +81,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
findPendingByCompanyId: jest.fn(async () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||
),
|
||||
findLatestOpenByCompanyId: jest.fn(async () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||
),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
@@ -88,6 +91,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "rev-1",
|
||||
...row,
|
||||
})),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
@@ -118,6 +128,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.revisionRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
|
||||
@@ -39,6 +39,7 @@ function makeService(existing: ExistingProfile[]) {
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
@@ -9,6 +10,11 @@ import { DataSource, EntityManager } from "typeorm";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
import {
|
||||
diffCompanyUpdate,
|
||||
summarizeCompanyChanges,
|
||||
} from "./company-revision-diff.util";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import {
|
||||
CompanyDashboardRepository,
|
||||
@@ -64,6 +70,10 @@ import {
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "./entities/company-change-request.entity";
|
||||
import {
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
} from "./entities/company-revision.entity";
|
||||
|
||||
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||
const LICENSE_RESOURCE = "company_profiles";
|
||||
@@ -81,6 +91,28 @@ const POA_ATTRIBUTES = [
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const;
|
||||
/**
|
||||
* Personnel an approved company maintains itself: its contact person, its
|
||||
* general manager and its Power of Attorney. These name who to talk to, not
|
||||
* what the company is allowed to do, so freezing the settings page until a
|
||||
* reviewer gets to a new phone number costs more than it protects. They write
|
||||
* straight to the live row even for an active company.
|
||||
*
|
||||
* The PoA's *delegation letter* is deliberately not here — the paper is the
|
||||
* thing that actually evidences the delegation, so it still goes through
|
||||
* review (see `uploadPoaDelegationLetter`), as does the owner's own identity.
|
||||
*/
|
||||
const SELF_SERVICE_ATTRIBUTES: readonly string[] = [
|
||||
"contactPersonName",
|
||||
"contactPersonPosition",
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
"contactVerifiedPhone",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
...POA_ATTRIBUTES,
|
||||
];
|
||||
/** Mandatory once the company operates as a freight forwarder. */
|
||||
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: "poaName", label: "PoA name" },
|
||||
@@ -154,10 +186,13 @@ export interface UserIdentity {
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
private readonly logger = new Logger(CompaniesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
||||
private readonly revisionRepo: CompanyRevisionRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly filesService: FilesService,
|
||||
@@ -660,7 +695,16 @@ export class CompaniesService {
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
const before = await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto };
|
||||
// Staff can also promote Pending -> Active directly through this generic
|
||||
// endpoint (not just via the first-profile-approval path), so stamp it here too.
|
||||
if (
|
||||
dto.status === CompanyStatus.Active &&
|
||||
before.status !== CompanyStatus.Active
|
||||
) {
|
||||
patch.approvedAt = new Date();
|
||||
}
|
||||
const updated = await this.companiesRepo.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||
|
||||
// Suspending or blacklisting locks the customer out, so they must be told.
|
||||
@@ -818,6 +862,34 @@ export class CompaniesService {
|
||||
return companyUpdates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a version-history entry for an onboarding-phase edit (the company
|
||||
* is not yet Active, so the change went straight to the live row with no
|
||||
* approval gate to carry a record of it). Best-effort: a no-op patch or a
|
||||
* failure to write history must never break the edit that triggered it.
|
||||
*/
|
||||
private async recordCompanyRevision(
|
||||
before: Company,
|
||||
patch: Record<string, any>,
|
||||
actorId?: string | null,
|
||||
extraChanges: CompanyRevisionChange[] = [],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const changes = [...diffCompanyUpdate(before, patch), ...extraChanges];
|
||||
if (changes.length === 0) return;
|
||||
await this.revisionRepo.create({
|
||||
companyId: before.id,
|
||||
actorId: actorId ?? null,
|
||||
summary: summarizeCompanyChanges(changes),
|
||||
changes,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record company revision for ${before.id}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a TIN already registered to a *different* company. */
|
||||
private async assertTinAvailable(
|
||||
company: Company,
|
||||
@@ -844,10 +916,11 @@ export class CompaniesService {
|
||||
*
|
||||
* - Company not yet approved (onboarding) → write straight to the Company row,
|
||||
* as before. The company/role pending→approve gate already covers first-run.
|
||||
* - Company already `active` → do NOT touch the live Company. Stage the edit in
|
||||
* a pending change request (merging into any open one) so a backoffice
|
||||
* reviewer can approve (apply) or reject (with a note). This locks the
|
||||
* customer until the review resolves.
|
||||
* - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
|
||||
* still write straight through; everything else does NOT touch the live
|
||||
* Company but is staged in a pending change request (merging into any open
|
||||
* one) so a backoffice reviewer can approve (apply) or reject (with a
|
||||
* note). Only the staged half locks the customer until the review resolves.
|
||||
*/
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
@@ -879,12 +952,41 @@ export class CompaniesService {
|
||||
);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
await this.recordCompanyRevision(company, companyUpdates, userId);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
// Approved company: stage the change for review, leaving the live row intact.
|
||||
// Approved company: personnel details apply immediately, the rest is staged
|
||||
// for review with the live row left intact.
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
const fields = this.pickDefined(dto);
|
||||
const selfService: Record<string, any> = {};
|
||||
const staged: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value;
|
||||
else staged[key] = value;
|
||||
}
|
||||
|
||||
let live = company;
|
||||
if (Object.keys(selfService).length > 0) {
|
||||
live =
|
||||
(await this.companiesRepo.update(
|
||||
company.id,
|
||||
this.mapProfileDtoToCompanyUpdates(company, selfService),
|
||||
)) ?? company;
|
||||
live.companyProfiles = company.companyProfiles;
|
||||
}
|
||||
|
||||
if (Object.keys(staged).length === 0) {
|
||||
// Nothing a reviewer needs to see. Any request already open (a document
|
||||
// upload, an owner verification) still surfaces so its banner survives —
|
||||
// it just no longer gains fields it was never asked to review.
|
||||
return new ProfileResponseDto(
|
||||
profile,
|
||||
live,
|
||||
await this.changeRequestRepo.findLatestOpenByCompanyId(company.id),
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||
company.id,
|
||||
@@ -894,10 +996,14 @@ export class CompaniesService {
|
||||
if (existing) {
|
||||
request =
|
||||
(await this.changeRequestRepo.update(existing.id, {
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...fields },
|
||||
// Note is left untouched: if this request was ChangesRequested, the
|
||||
// reviewer's ask stays visible on the resubmitted (Pending) row —
|
||||
// clearing it here would hide what was asked for right when the
|
||||
// reviewer comes back to check whether it was actually addressed.
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
})) ?? existing;
|
||||
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
||||
} else {
|
||||
@@ -910,7 +1016,7 @@ export class CompaniesService {
|
||||
);
|
||||
request = await this.changeRequestRepo.create({
|
||||
companyId: company.id,
|
||||
snapshot: fields,
|
||||
snapshot: staged,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
@@ -922,8 +1028,9 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Live company is unchanged; surface the pending state for the settings page.
|
||||
return new ProfileResponseDto(profile, company, request);
|
||||
// Only the personnel half (if any) landed; surface the pending state for
|
||||
// the settings page.
|
||||
return new ProfileResponseDto(profile, live, request);
|
||||
}
|
||||
|
||||
/** List a company's change requests, newest first (backoffice review). */
|
||||
@@ -934,6 +1041,53 @@ export class CompaniesService {
|
||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
|
||||
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
|
||||
await this.findCompanyById(companyId);
|
||||
return this.revisionRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair adjacent remove-then-add intents into one before/after revision
|
||||
* change — that's exactly how a "replace" is staged (see
|
||||
* `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]`
|
||||
* pushed together, and later merges only ever append after that pair, so
|
||||
* adjacency is preserved). A remove or add with no adjacent partner (a pure
|
||||
* add, or a pure removal) stands alone.
|
||||
*/
|
||||
private pairReplaceIntents<
|
||||
T extends { op: "add" | "remove"; fileId: string; fileName?: string },
|
||||
>(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] {
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
let i = 0;
|
||||
while (i < intents.length) {
|
||||
const current = intents[i];
|
||||
const next = intents[i + 1];
|
||||
if (current.op === "remove" && next?.op === "add") {
|
||||
changes.push({
|
||||
field: `document:${current.fileId}`,
|
||||
label: labelFor(next),
|
||||
from: current.fileName ?? null,
|
||||
to: next.fileName ?? null,
|
||||
fromFileId: current.fileId,
|
||||
toFileId: next.fileId,
|
||||
});
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
changes.push({
|
||||
field: `document:${current.fileId}`,
|
||||
label: labelFor(current),
|
||||
from: current.op === "remove" ? (current.fileName ?? null) : null,
|
||||
to: current.op === "add" ? (current.fileName ?? null) : null,
|
||||
fromFileId: current.op === "remove" ? current.fileId : null,
|
||||
toFileId: current.op === "add" ? current.fileId : null,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending change request: apply its snapshot to the live Company and
|
||||
* mark the request approved. Any staged documents are already attached to the
|
||||
@@ -963,6 +1117,30 @@ export class CompaniesService {
|
||||
await this.applyLicenseChanges(request);
|
||||
await this.applyDocumentChanges(request);
|
||||
|
||||
// This is the ONLY place post-approval FIELD/license/PoA-document changes
|
||||
// land on the live row — without this call, everything the #419
|
||||
// change-request flow does to those is invisible in Version History.
|
||||
// `documentFileIds` (the general bulk company-documents upload) is
|
||||
// deliberately NOT re-recorded here — those documents go live immediately
|
||||
// at upload time and are already recorded there (see
|
||||
// `uploadCompanyDocuments`); redoing it here would double the entry.
|
||||
const documentChanges: CompanyRevisionChange[] = [
|
||||
...this.pairReplaceIntents(
|
||||
request.documents?.licenseChanges ?? [],
|
||||
() => "Business license",
|
||||
),
|
||||
...this.pairReplaceIntents(
|
||||
request.documents?.documentChanges ?? [],
|
||||
(intent) => intent.code,
|
||||
),
|
||||
];
|
||||
await this.recordCompanyRevision(
|
||||
company,
|
||||
companyUpdates,
|
||||
reviewerId,
|
||||
documentChanges,
|
||||
);
|
||||
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Approved,
|
||||
@@ -973,12 +1151,62 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh upload under a single-file document slot (`isMultiple: false`)
|
||||
* replaces whatever was there, not adds to it — soft-delete the prior live
|
||||
* file(s) for that code, and describe each replacement (plus each genuinely
|
||||
* new upload) as a revision change carrying both file ids, so the reviewer
|
||||
* can open the previous and current file. Multi-file slots are left alone
|
||||
* (genuinely additive, no single "the" document to diff against). Unrecognised
|
||||
* codes (no matching field in the nationality's document setting) are also
|
||||
* left alone — safer to under-clean than to guess wrong. Independent of the
|
||||
* change-request review outcome: nothing else in this flow ever retires a
|
||||
* superseded document, on approve OR reject — these documents go live the
|
||||
* moment they're uploaded.
|
||||
*/
|
||||
private async replaceSingleFileCompanyDocuments(
|
||||
company: Company,
|
||||
before: FileRecord[],
|
||||
uploaded: FileRecord[],
|
||||
): Promise<CompanyRevisionChange[]> {
|
||||
const setting = await this.fileUploadSettingsService
|
||||
.getByCode(this.documentSettingCodeFor(company.nationality))
|
||||
.catch(() => null);
|
||||
const fields = setting?.fields ?? [];
|
||||
const singleFileCodes = new Set(
|
||||
fields.filter((f) => !f.isMultiple).map((f) => f.fileKey),
|
||||
);
|
||||
const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel]));
|
||||
const uploadedIds = new Set(uploaded.map((f) => f.id));
|
||||
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
const toRemove: FileRecord[] = [];
|
||||
for (const file of uploaded) {
|
||||
if (!singleFileCodes.has(file.code)) continue;
|
||||
const prior = before.find(
|
||||
(f) => f.code === file.code && !uploadedIds.has(f.id),
|
||||
);
|
||||
changes.push({
|
||||
field: `document:${file.code}`,
|
||||
label: labelByCode.get(file.code) ?? file.code,
|
||||
from: prior?.name ?? null,
|
||||
to: file.name,
|
||||
fromFileId: prior?.id ?? null,
|
||||
toFileId: file.id,
|
||||
});
|
||||
if (prior) toRemove.push(prior);
|
||||
}
|
||||
await Promise.all(toRemove.map((f) => this.filesService.remove(f.id)));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload company documents. For an approved company this also opens/updates a
|
||||
* pending change request (recording the uploaded file ids) so the upload is
|
||||
* reviewed and the customer is locked until it clears — consistent with the
|
||||
* field-edit review. During onboarding (company not yet active) it's a plain
|
||||
* upload with no review.
|
||||
* upload with no review. Either way the documents go live immediately, so
|
||||
* the revision history is recorded right away too, not gated on a decision.
|
||||
*/
|
||||
async uploadCompanyDocuments(
|
||||
companyId: string,
|
||||
@@ -986,11 +1214,20 @@ export class CompaniesService {
|
||||
submittedBy?: string,
|
||||
): Promise<FileRecord[]> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const before = await this.filesService.findByResource(
|
||||
companyId,
|
||||
"companies",
|
||||
);
|
||||
const uploaded = await this.filesService.uploadMany(
|
||||
companyId,
|
||||
"companies",
|
||||
files,
|
||||
);
|
||||
const documentChanges = await this.replaceSingleFileCompanyDocuments(
|
||||
company,
|
||||
before,
|
||||
uploaded,
|
||||
);
|
||||
await this.resolveDocumentChangeRequests(
|
||||
companyId,
|
||||
"companies",
|
||||
@@ -1004,6 +1241,9 @@ export class CompaniesService {
|
||||
submittedBy,
|
||||
);
|
||||
}
|
||||
if (documentChanges.length > 0) {
|
||||
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
@@ -1118,7 +1358,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
@@ -1179,6 +1420,37 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for specific fixes without rejecting outright: unlike
|
||||
* {@link rejectChangeRequest}, staged license/document intents are kept (the
|
||||
* row stays open), so the customer's next edit is appended to this SAME
|
||||
* request — via the merge branches in `updateProfile`/`stageDocumentChange`/
|
||||
* `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead
|
||||
* of starting a fresh cycle.
|
||||
*/
|
||||
async requestChangeRequestChanges(
|
||||
id: string,
|
||||
note: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
);
|
||||
}
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.ChangesRequested,
|
||||
note,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
})) ?? request
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCompany(id: string): Promise<void> {
|
||||
await this.findCompanyById(id);
|
||||
await this.companiesRepo.softDelete(id);
|
||||
@@ -1420,6 +1692,7 @@ export class CompaniesService {
|
||||
) {
|
||||
await companyRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
this.companyNotifier.companyApproved(company);
|
||||
}
|
||||
@@ -1720,16 +1993,11 @@ export class CompaniesService {
|
||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
// An Ethiopian company does not type its PoA details at all — they arrive
|
||||
// from the Fayda verification — so reporting them as missing fields would
|
||||
// ask for something the form no longer offers. The identity block below
|
||||
// No company types its PoA details — they arrive from the Fayda
|
||||
// verification whatever the nationality — so reporting them as missing
|
||||
// fields would ask for something no form offers. The identity block below
|
||||
// reports "verify your PoA" instead.
|
||||
const missingPoaFields =
|
||||
poaRequired && !identity.faydaRequired
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
|
||||
const delegation = await this.getPoaDelegationState(company.id);
|
||||
const delegationDue = poaRequired || poaProvided;
|
||||
const missingDelegation = delegationDue && !delegation.onFile;
|
||||
@@ -1754,9 +2022,7 @@ export class CompaniesService {
|
||||
...(identity.faydaRequired && !identity.owner.verified
|
||||
? ["Verify the company owner's identity with Fayda"]
|
||||
: []),
|
||||
...(identity.faydaRequired &&
|
||||
(poaRequired || poaProvided) &&
|
||||
!identity.poa.verified
|
||||
...((poaRequired || poaProvided) && !identity.poa.verified
|
||||
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||
: []),
|
||||
...(identity.passportRequired && !identity.owner.passportNumber
|
||||
@@ -1768,26 +2034,20 @@ export class CompaniesService {
|
||||
// fields, required documents, one license per operational profile, and the
|
||||
// PoA details/paper whenever those are mandatory.
|
||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||
const poaItemCount =
|
||||
(poaRequired && !identity.faydaRequired
|
||||
? REQUIRED_POA_FIELDS.length
|
||||
: 0) + (delegationDue ? 1 : 0);
|
||||
const poaItemCount = delegationDue ? 1 : 0;
|
||||
// One item per identity credential the company has to prove: the owner
|
||||
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
|
||||
// is one and Fayda is what's mandatory here.
|
||||
const identityItemCount = identity.faydaRequired
|
||||
? delegationDue
|
||||
? 2
|
||||
: 1
|
||||
: identity.passportRequired
|
||||
? 1
|
||||
: 0;
|
||||
const missingIdentityCount = identity.faydaRequired
|
||||
? (identity.owner.verified ? 0 : 1) +
|
||||
(delegationDue && !identity.poa.verified ? 1 : 0)
|
||||
: identity.passportRequired && !identity.owner.passportNumber
|
||||
? 1
|
||||
: 0;
|
||||
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
||||
// there is one — that one is Fayda whatever the nationality.
|
||||
const ownerCredentialDue =
|
||||
identity.faydaRequired || identity.passportRequired;
|
||||
const ownerCredentialProven = identity.faydaRequired
|
||||
? identity.owner.verified
|
||||
: Boolean(identity.owner.passportNumber);
|
||||
const identityItemCount =
|
||||
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||
const missingIdentityCount =
|
||||
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||
(delegationDue && !identity.poa.verified ? 1 : 0);
|
||||
const total =
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
@@ -2225,7 +2485,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
@@ -2453,11 +2714,13 @@ export class CompaniesService {
|
||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||
};
|
||||
|
||||
// An approved company's profile edits are staged for backoffice review, and
|
||||
// swapping the person who can act for the company is exactly the kind of
|
||||
// edit that review exists for — so a verification lands the same way an
|
||||
// ordinary edit does, rather than quietly rewriting a live record.
|
||||
if (company.status === CompanyStatus.Active) {
|
||||
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||
// is staged for backoffice review rather than quietly rewriting a live
|
||||
// record. The PoA is personnel — the company names its own representative,
|
||||
// and the delegation letter backing them is what the reviewer sees — so a
|
||||
// PoA verification lands live, matching the typed PoA fields in
|
||||
// `SELF_SERVICE_ATTRIBUTES`.
|
||||
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
|
||||
await this.stageIdentityChange(company, userId, identity);
|
||||
return this.getCompanyIdentityState(company);
|
||||
}
|
||||
@@ -2549,7 +2812,8 @@ export class CompaniesService {
|
||||
snapshot,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
return;
|
||||
@@ -2586,21 +2850,23 @@ export class CompaniesService {
|
||||
): void {
|
||||
const state = buildCompanyIdentityState(company);
|
||||
|
||||
// Only the owner's credential is nationality-specific: Fayda for an
|
||||
// Ethiopian company, a typed passport number for a foreign one.
|
||||
if (state.passportRequired) {
|
||||
if (!state.owner.passportNumber) {
|
||||
throw new BadRequestException(
|
||||
"Add the company owner's passport number before continuing.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.owner.verified) {
|
||||
} else if (!state.owner.verified) {
|
||||
throw new BadRequestException(
|
||||
"Verify the company owner's identity with Fayda before continuing.",
|
||||
);
|
||||
}
|
||||
|
||||
// The representative is not. A PoA acts for the company inside Ethiopia
|
||||
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
|
||||
// a foreign company nominates one rather than typing a name.
|
||||
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
@@ -2827,7 +3093,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Repository } from "typeorm";
|
||||
import { FindOperator, Repository } from "typeorm";
|
||||
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import {
|
||||
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
|
||||
|
||||
const COMPANY_ID = "company-1";
|
||||
|
||||
/** Matches a row's status against either a plain value or an `In([...])` operator. */
|
||||
function statusMatches(
|
||||
rowStatus: ChangeRequestStatus,
|
||||
where: ChangeRequestStatus | FindOperator<ChangeRequestStatus> | undefined,
|
||||
): boolean {
|
||||
if (where === undefined) return true;
|
||||
if (where instanceof FindOperator) return where.value.includes(rowStatus);
|
||||
return rowStatus === where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
|
||||
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
||||
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
|
||||
function mockRepositoryOver(rows: Row[]) {
|
||||
return {
|
||||
findOne: jest.fn(
|
||||
({ where }: { where: Partial<Row> & { companyId: string } }) =>
|
||||
({
|
||||
where,
|
||||
}: {
|
||||
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
rows
|
||||
.filter(
|
||||
(row) =>
|
||||
where.companyId === COMPANY_ID &&
|
||||
(where.status === undefined || row.status === where.status),
|
||||
statusMatches(row.status, where.status),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
||||
null,
|
||||
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
|
||||
expect(result?.id).toBe("pending");
|
||||
});
|
||||
|
||||
it("treats a changes-requested request as open, same as pending", async () => {
|
||||
const changesRequested: Row = {
|
||||
id: "changes-requested",
|
||||
status: ChangeRequestStatus.ChangesRequested,
|
||||
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const result = await subject([
|
||||
rejected,
|
||||
changesRequested,
|
||||
]).findLatestOpenByCompanyId(COMPANY_ID);
|
||||
|
||||
expect(result?.id).toBe("changes-requested");
|
||||
});
|
||||
|
||||
it("returns the latest rejected request when nothing is pending", async () => {
|
||||
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
||||
COMPANY_ID,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */
|
||||
const OPEN_FOR_EDIT_STATUSES = [
|
||||
ChangeRequestStatus.Pending,
|
||||
ChangeRequestStatus.ChangesRequested,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||
constructor(
|
||||
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The company's current pending request, if any. */
|
||||
/** The company's current open request (Pending or ChangesRequested), if any — the row the next edit appends to. */
|
||||
async findPendingByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
||||
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 →
|
||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
||||
* `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit
|
||||
* call to action, but the customer's edit appends to this SAME request
|
||||
* instead of starting a fresh one.
|
||||
*/
|
||||
review: {
|
||||
status: 'pending' | 'rejected';
|
||||
status: 'pending' | 'rejected' | 'changes_requested';
|
||||
note: string | null;
|
||||
} | null;
|
||||
|
||||
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
|
||||
const open =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.review = open
|
||||
? {
|
||||
status: open.status as 'pending' | 'rejected',
|
||||
status: open.status as 'pending' | 'rejected' | 'changes_requested',
|
||||
note: open.note ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -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,
|
||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||
|
||||
const complete = faydaRequired
|
||||
? owner.verified && (!poaDue || poa.verified)
|
||||
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
||||
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
|
||||
// proven with Fayda — a foreign company nominates a representative who holds
|
||||
// one rather than typing a name nothing backs.
|
||||
const ownerProven = faydaRequired
|
||||
? owner.verified
|
||||
: !passportRequired || Boolean(owner.passportNumber);
|
||||
const complete = ownerProven && (!poaDue || poa.verified);
|
||||
|
||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||
}
|
||||
|
||||
@@ -68,10 +68,12 @@ export class ProfileResponseDto {
|
||||
|
||||
/**
|
||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
||||
* settings page; `"rejected"`/`"changes_requested"` both surface the note and
|
||||
* prefill the proposed values from `pendingChanges` so the customer can amend
|
||||
* & resubmit — `"changes_requested"` just appends the edit to this same
|
||||
* request instead of starting a fresh one.
|
||||
*/
|
||||
reviewStatus: "pending" | "rejected" | null;
|
||||
reviewStatus: "pending" | "rejected" | "changes_requested" | null;
|
||||
reviewNote: string | null;
|
||||
pendingChanges: Record<string, any> | null;
|
||||
|
||||
@@ -127,7 +129,8 @@ export class ProfileResponseDto {
|
||||
const openReview =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.reviewStatus =
|
||||
@@ -135,7 +138,9 @@ export class ProfileResponseDto {
|
||||
? "pending"
|
||||
: openReview?.status === ChangeRequestStatus.Rejected
|
||||
? "rejected"
|
||||
: null;
|
||||
: openReview?.status === ChangeRequestStatus.ChangesRequested
|
||||
? "changes_requested"
|
||||
: null;
|
||||
this.reviewNote = openReview?.note ?? null;
|
||||
this.pendingChanges = openReview?.snapshot ?? null;
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
|
||||
@@ -97,6 +97,7 @@ export class ResponseCompanyDto {
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
approvedAt: Date | null;
|
||||
|
||||
constructor(company: Company) {
|
||||
this.id = company.id;
|
||||
@@ -135,5 +136,6 @@ export class ResponseCompanyDto {
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
this.approvedAt = company.approvedAt ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
|
||||
/**
|
||||
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
||||
* settings page by an already-approved company are staged here (not written to
|
||||
* the live Company row) until a backoffice reviewer approves — at which point
|
||||
* the snapshot is applied — or rejects with a note, after which the customer can
|
||||
* amend and resubmit.
|
||||
* the live Company row) until a backoffice reviewer resolves it:
|
||||
* - Approved — the snapshot is applied to the live Company row.
|
||||
* - Rejected — terminal for this row; the customer's next edit starts a fresh one.
|
||||
* - ChangesRequested — soft: the row stays open with the reviewer's note attached,
|
||||
* so the customer's next edit is appended (merged) into this SAME row instead
|
||||
* of starting a new cycle.
|
||||
*/
|
||||
export enum ChangeRequestStatus {
|
||||
Pending = "pending",
|
||||
Approved = "approved",
|
||||
Rejected = "rejected",
|
||||
ChangesRequested = "changes_requested",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
/** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */
|
||||
@Column({ name: "approved_at", type: "timestamptz", nullable: true })
|
||||
approvedAt?: Date | null;
|
||||
|
||||
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
|
||||
tin!: string;
|
||||
|
||||
|
||||
@@ -51,7 +51,11 @@ export class FilesController {
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const record = await this.filesService.findById(fileId);
|
||||
// Includes soft-deleted records: a superseded document (replaced via a
|
||||
// single-file document slot, or resolved as part of a license/PoA swap)
|
||||
// is only reachable by UUID through the change-request/version-history
|
||||
// diff, where reviewers need to open the "previous" file to compare it.
|
||||
const record = await this.filesService.findByIdIncludingDeleted(fileId);
|
||||
|
||||
// Chat attachments are cross-tenant sensitive and this route has no
|
||||
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
||||
@@ -63,7 +67,9 @@ export class FilesController {
|
||||
);
|
||||
}
|
||||
|
||||
const { stream } = await this.filesService.streamById(fileId);
|
||||
const { stream } = await this.filesService.streamById(fileId, {
|
||||
includeDeleted: true,
|
||||
});
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
const disposition = forceDownload ? "attachment" : "inline";
|
||||
|
||||
|
||||
@@ -245,6 +245,23 @@ export class FilesService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link findById}, but also matches a soft-deleted record — a
|
||||
* superseded document (replaced via a single-file slot, or a resolved
|
||||
* license/PoA swap) is exactly this: gone from every live listing, but its
|
||||
* id is still handed to reviewers in the change-request/version-history
|
||||
* diff so they can open the "previous" file for comparison. Only the
|
||||
* preview/download route should use this; every other caller wants the
|
||||
* default (soft-deleted = not found).
|
||||
*/
|
||||
async findByIdIncludingDeleted(id: string): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findById(id, {
|
||||
withDeleted: true,
|
||||
});
|
||||
if (!record) throw new NotFoundException(`File ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a reviewer verdict on one document. `change_requested` keeps the note
|
||||
* (the customer sees it verbatim); any other verdict clears it, so a stale
|
||||
@@ -360,8 +377,11 @@ export class FilesService {
|
||||
|
||||
async streamById(
|
||||
id: string,
|
||||
opts: { includeDeleted?: boolean } = {},
|
||||
): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
const record = await this.findById(id);
|
||||
const record = opts.includeDeleted
|
||||
? await this.findByIdIncludingDeleted(id)
|
||||
: await this.findById(id);
|
||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
return { stream, record };
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||
type PaymentType = string
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill"
|
||||
type Currency = "ETB" | "USD"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
|
||||
@@ -22,7 +22,7 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
|
||||
referenceType?: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
forwardRef,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryRequestDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payment.dto";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
|
||||
/**
|
||||
* Consumer side of the payment microservice's outbox relay.
|
||||
* WARNING: currently unauthenticated — anyone who can reach the API can mark
|
||||
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
|
||||
* Consumer side of the payment microservice's outbox relay. Only the payment service may
|
||||
* call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8).
|
||||
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
||||
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
@Public()
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentController {
|
||||
private readonly logger = new Logger(InternalPaymentController.name);
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
constructor(
|
||||
private readonly paymentService: PaymentService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billingService: BillingService,
|
||||
) { }
|
||||
|
||||
@Post("mark-paid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -36,4 +48,16 @@ export class InternalPaymentController {
|
||||
this.logger.log(`Marking payment ${event} as PAID`);
|
||||
return this.paymentService.handlePaymentEvent(event);
|
||||
}
|
||||
|
||||
@Post("bill-query")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
|
||||
})
|
||||
async billQuery(
|
||||
@Body() request: BillQueryRequestDto,
|
||||
): Promise<BillQueryResponseDto> {
|
||||
return this.billingService.billQuery(request.referenceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +51,30 @@ export class MarkPaidResponseDto {
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
|
||||
* "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
|
||||
*/
|
||||
export class BillQueryRequestDto {
|
||||
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
|
||||
// property to a relative require() into packages/types, which does not exist inside the
|
||||
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
|
||||
// decorators below still give us enum docs + runtime validation.
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: string;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
}
|
||||
|
||||
export class BillQueryResponseDto {
|
||||
@ApiProperty() stillPayable!: boolean;
|
||||
@ApiPropertyOptional() payerName?: string | null;
|
||||
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
||||
@ApiPropertyOptional() currency?: string | null;
|
||||
/** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
|
||||
@ApiPropertyOptional() paymentReason?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
@@ -16,85 +21,125 @@ import {
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentClientService {
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
// process.env.PAYMENT_API_URL ??
|
||||
"https://paymentcallback.triaplc.com"
|
||||
// "http://localhost:3003"
|
||||
).replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||
)
|
||||
// "http://localhost:3003"
|
||||
.replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
constructor(private readonly http: HttpService) { }
|
||||
constructor(private readonly http: HttpService) { }
|
||||
|
||||
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
|
||||
async initiate(request: InitiatePaymentRequest): Promise<PaymentIntentSnapshot> {
|
||||
return this.call("POST", "/payments/initiate", request);
|
||||
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
|
||||
async initiate(
|
||||
request: InitiatePaymentRequest,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
return this.call("POST", "/payments/initiate", request);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/reconcile — settlement check for a domain order
|
||||
* (reconcile-before-cancel). Live-queries every non-failed intent at the
|
||||
* provider and registers any late capture found (flips it to SUCCEEDED and
|
||||
* emits payment.succeeded). `unverifiable: true` = could not confirm
|
||||
* "not paid" — the caller must NOT cancel/expire the order.
|
||||
*/
|
||||
async reconcileReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
||||
return this.call("POST", "/payments/reconcile", {
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
|
||||
async getIntentByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntentSnapshot | null> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/intents?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404)
|
||||
return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/reconcile — settlement check for a domain order
|
||||
* (reconcile-before-cancel). Live-queries every non-failed intent at the
|
||||
* provider and registers any late capture found (flips it to SUCCEEDED and
|
||||
* emits payment.succeeded). `unverifiable: true` = could not confirm
|
||||
* "not paid" — the caller must NOT cancel/expire the order.
|
||||
*/
|
||||
async reconcileReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
||||
return this.call("POST", "/payments/reconcile", {
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
/**
|
||||
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider
|
||||
* (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service;
|
||||
* surface that as a BadRequest (retryable) rather than a 502, so the payer can
|
||||
* re-enter the code.
|
||||
*/
|
||||
async confirmOtp(
|
||||
intentId: string,
|
||||
otp: string,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
try {
|
||||
return await this.call<PaymentIntentSnapshot>(
|
||||
"POST",
|
||||
`/payments/intents/${intentId}/confirm`,
|
||||
{ otp },
|
||||
);
|
||||
} catch (err) {
|
||||
// `call` re-throws raw 404s and masks every other 4xx as BadGateway; an
|
||||
// unknown intent or a bad OTP is client-fixable, so translate both to 400.
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
throw new BadRequestException("PaymentIntent not found");
|
||||
}
|
||||
if (err instanceof BadGatewayException) {
|
||||
const detail = err.message.replace(/^Payment service error: /, "");
|
||||
throw new BadRequestException(detail);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
|
||||
async getIntentByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntentSnapshot | null> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/intents?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
method,
|
||||
url,
|
||||
data: body,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
this.logger.error(
|
||||
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||
}
|
||||
const message = err instanceof Error && err.message ? err.message : String(err);
|
||||
this.logger.error(`payment service unreachable (${method} ${path}): ${message}`);
|
||||
throw new BadGatewayException("Payment service unreachable");
|
||||
}
|
||||
private async call<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
method,
|
||||
url,
|
||||
data: body,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
this.logger.error(
|
||||
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||
}
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`payment service unreachable (${method} ${path}): ${message}`,
|
||||
);
|
||||
throw new BadGatewayException("Payment service unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
// CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old
|
||||
// 10s cap 502'd every CAC charge while the bank was still working —
|
||||
// orphaning an intent the payer had already been texted about. Matches
|
||||
// the passenger API's budget.
|
||||
HttpModule.register({
|
||||
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
|
||||
}),
|
||||
ConfigModule,
|
||||
forwardRef(() => BillingModule),
|
||||
// forwardRef(() => TrainSchedulingModule),
|
||||
|
||||
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;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
/** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */
|
||||
payerName?: string;
|
||||
/** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface InitiateIntentResult {
|
||||
@@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
CBE_BILL: "cbe-bill",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -214,8 +219,13 @@ export class PaymentService {
|
||||
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
try {
|
||||
|
||||
|
||||
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
|
||||
if (isCbeBill && input.currency?.toUpperCase() !== "ETB") {
|
||||
throw new BadRequestException(
|
||||
"CBE bill payment is only available for ETB invoices",
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
@@ -223,11 +233,15 @@ export class PaymentService {
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
// amountMinor: input.amountMinor,
|
||||
amountMinor:1,
|
||||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||||
// debited against the intent amount, so the 1-birr dev shortcut would break it.
|
||||
amountMinor: isCbeBill ? input.amountMinor : 1,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
payerName: input.payerName,
|
||||
expiresAt: input.expiresAt,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
@@ -374,6 +388,53 @@ export class PaymentService {
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
|
||||
* id (the invoice's `paymentId`) so the right invoice settles even when several
|
||||
* invoices share a domain reference. The active gateway intent is looked up by
|
||||
* reference, the OTP is forwarded, and the projection is refreshed. On success
|
||||
* billing settles the linked invoice (idempotent — the outbox path converges too).
|
||||
* A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
|
||||
*/
|
||||
async confirmOtp(intentId: string, otp: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ id: intentId });
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
(local.referenceType as PaymentReferenceType) ??
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
local.refId,
|
||||
);
|
||||
if (!snapshot) {
|
||||
throw new NotFoundException("No active payment to confirm");
|
||||
}
|
||||
|
||||
const confirmed = await this.paymentClient.confirmOtp(
|
||||
snapshot.intentId,
|
||||
otp,
|
||||
);
|
||||
|
||||
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.markIntentSucceeded(local.id, {
|
||||
providerTxnId: confirmed.providerTxnId,
|
||||
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
} else {
|
||||
await this.paymentRepo.update(
|
||||
{ id: local.id },
|
||||
{
|
||||
status: this.toLocalStatus(confirmed.status),
|
||||
failerCode: confirmed.failureCode ?? undefined,
|
||||
failureMessage: confirmed.failureMessage ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||
|
||||
@@ -60,8 +60,10 @@ export class RefundDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
})
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@@ -80,6 +82,17 @@ export class ClientActionDto {
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
message?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
|
||||
})
|
||||
billReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
instructions?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
|
||||
@@ -158,6 +158,14 @@ export class BookingJourneyService {
|
||||
this.events.emit('booking.completed', { bookingId });
|
||||
}
|
||||
|
||||
// The cargo is physically off the train at its own yard — mid-corridor or
|
||||
// final. WarehouseInventoryService picks this up to create the warehouse
|
||||
// record (import/intercity only; export already has one from receive).
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
});
|
||||
|
||||
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||
void this.completeMilestones(booking, [
|
||||
...(booking.tradeDirection === 'IMPORT'
|
||||
@@ -319,11 +327,19 @@ export class BookingJourneyService {
|
||||
RETURNING b.id, b.trade_direction`,
|
||||
[schedule.id, schedule.destinationStationId, now],
|
||||
);
|
||||
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
||||
for (const row of rows) {
|
||||
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
||||
if (row.trade_direction === 'DOMESTIC') {
|
||||
this.events.emit('booking.completed', { bookingId: row.id });
|
||||
}
|
||||
// Same event the per-booking unloadBooking() path emits — WarehouseInventoryService
|
||||
// listens for this to auto-create the warehouse_inventory row (import/intercity only,
|
||||
// it filters EXPORT itself). The bulk SQL update above skipped this entirely, so
|
||||
// bookings caught by this fallback never left "awaiting unload".
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId: row.id,
|
||||
tradeDirection: row.trade_direction,
|
||||
});
|
||||
}
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
@@ -162,25 +162,36 @@ describe('applyWagonOrderReversal', () => {
|
||||
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
|
||||
});
|
||||
|
||||
it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => {
|
||||
it('flips the position numbers when the flag is true', () => {
|
||||
const reversed = applyWagonOrderReversal(plan, true);
|
||||
// Physically-last wagon (was seq 3, wt-c) is now position 1.
|
||||
expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']);
|
||||
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||
// Physically-last wagon (wt-c) is now position 1.
|
||||
expect(reversed.map((s) => s.sequenceNo)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('keeps each booking with its own wagon — only the position changes', () => {
|
||||
const reversed = applyWagonOrderReversal(plan, true);
|
||||
// The booking that was in the last wagon now sits at sequenceNo 1.
|
||||
expect(reversed[0].sequenceNo).toBe(1);
|
||||
const atPosition1 = reversed.find((s) => s.sequenceNo === 1);
|
||||
expect(
|
||||
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
|
||||
(atPosition1?.allocations as { bookingId: string }[])[0].bookingId,
|
||||
).toBe('BKG-C');
|
||||
const atPosition3 = reversed.find((s) => s.sequenceNo === 3);
|
||||
expect(
|
||||
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
|
||||
(atPosition3?.allocations as { bookingId: string }[])[0].bookingId,
|
||||
).toBe('BKG-A');
|
||||
});
|
||||
|
||||
// The regression that emptied every reversed train's container items: the
|
||||
// placement generators pair unit k (booking order) with slot k of this array,
|
||||
// and persistAllocationsAndLoads matches that sequenceNo against the
|
||||
// allocation's booking. Array order must stay packing order.
|
||||
it('keeps array order aligned with booking order so placements still match', () => {
|
||||
const reversed = applyWagonOrderReversal(plan, true);
|
||||
expect(
|
||||
reversed.map((s) => (s.allocations as { bookingId: string }[])[0].bookingId),
|
||||
).toEqual(['BKG-A', 'BKG-B', 'BKG-C']);
|
||||
});
|
||||
|
||||
it('does not mutate the input plan', () => {
|
||||
applyWagonOrderReversal(plan, true);
|
||||
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||
|
||||
@@ -470,15 +470,22 @@ export function planWagonsWithStock(params: {
|
||||
* sequenceNos, the snapshot re-sorts by them, and the board/allocation views all
|
||||
* read them — so the stored train order and the schedule order stay identical,
|
||||
* just reversed. A false/absent flag returns the plan unchanged.
|
||||
*
|
||||
* Only the NUMBERS flip — the array itself stays in packing order. Container
|
||||
* placements are generated by walking the container units in booking order
|
||||
* against getContainerSlotSequenceNos(plan) in array order, then matched back to
|
||||
* their allocation by `sequenceNo:bookingId`. Reordering the array here broke
|
||||
* that pairing on every reversed schedule: unit 1 was handed the number of the
|
||||
* slot holding the LAST booking, the match missed, and persistAllocationsAndLoads
|
||||
* silently dropped every container item — which is why a reversed export train
|
||||
* printed a marshalling doc with no container numbers and 0/0 container counts.
|
||||
*/
|
||||
export function applyWagonOrderReversal(
|
||||
plan: WagonPlanSlot[],
|
||||
reverse: boolean | null | undefined,
|
||||
): WagonPlanSlot[] {
|
||||
if (!reverse) return plan;
|
||||
return [...plan]
|
||||
.reverse()
|
||||
.map((slot, index) => ({ ...slot, sequenceNo: index + 1 }));
|
||||
return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index }));
|
||||
}
|
||||
|
||||
/** Unbounded stock — used to compute pure demand for availability reporting. */
|
||||
|
||||
@@ -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.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[inventoryId],
|
||||
@@ -111,8 +109,11 @@ export class WarehouseInspectionService {
|
||||
readyForPickupAt: new Date(),
|
||||
});
|
||||
|
||||
const hasLastMile =
|
||||
Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile);
|
||||
// service_types.includes_last_mile is NOT read here — every service type
|
||||
// ships with it true, which made this always true regardless of the
|
||||
// customer's actual self-haul/EDR-haul choice and permanently dead-coded
|
||||
// the self-haul nudge below. The delivery address is the real signal.
|
||||
const hasLastMile = Boolean(row.lastMileDeliveryAddress?.trim?.());
|
||||
|
||||
if (row.bookingReference && hasLastMile) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
Between,
|
||||
@@ -1339,8 +1339,11 @@ export class WarehouseInventoryService {
|
||||
bcu.seal_numbers AS "sealNumbers",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
-- service_types.includes_last_mile/first_mile are NOT read here: every
|
||||
-- service type ships with both true, so OR-ing them in made this always
|
||||
-- true regardless of the customer's actual self-haul/EDR-haul choice.
|
||||
-- The address is the only per-booking record of that choice.
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
@@ -1351,8 +1354,7 @@ export class WarehouseInventoryService {
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.status AS "status",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus",
|
||||
fm.vehicle_id AS "firstMileVehicleId",
|
||||
@@ -1386,7 +1388,6 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
@@ -1494,8 +1495,8 @@ export class WarehouseInventoryService {
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
-- No service_types OR here either — see eligibleBookings above.
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
@@ -1519,14 +1520,12 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
b.company_id AS "companyId",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
${primaryContactUserJoin('company')}
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
@@ -1682,6 +1681,7 @@ export class WarehouseInventoryService {
|
||||
for (const pending of pendingNotifications) {
|
||||
void this.notifyOwnerInventoryReceived(pending.owner);
|
||||
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
|
||||
if (dto.direction === 'EXPORT') void this.notifyCarriageAcceptanceReady(pending.bookingId);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -1979,11 +1979,10 @@ export class WarehouseInventoryService {
|
||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||
ts.train_number AS "trainSchedule",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
-- No service_types OR here either — see eligibleBookings above.
|
||||
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||||
-- Multi-truck self-haul writes plates/drivers to
|
||||
-- customer_truck_assignments and leaves the booking columns null,
|
||||
-- so read the assignments first and keep the legacy column as the
|
||||
@@ -2018,7 +2017,6 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
@@ -2112,6 +2110,108 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking alighted from a train at ITS OWN destination yard — emitted by
|
||||
* BookingJourneyService.unloadBooking() for every direction, whether that
|
||||
* yard is a mid-corridor stop (checkpoint auto-unload) or the train's final
|
||||
* yard (manual per-booking unload). That per-booking flow only ever flips
|
||||
* booking.status; it never creates a warehouse_inventory row, which used to
|
||||
* strand mid-corridor IMPORT and DOMESTIC/intercity bookings — their status
|
||||
* read ARRIVED/COMPLETED but the Arrival Queue's unload count never moved
|
||||
* (nothing else was watching for a mid-corridor arrival). This creates that
|
||||
* row the moment the cargo is physically off the train.
|
||||
*
|
||||
* EXPORT is deliberately skipped: its warehouse_inventory row (and GRN) is
|
||||
* created at the ORIGIN warehouse receive, before the cargo ever boards —
|
||||
* see BookingsService.carriageAcceptanceSheet and receive()/bulkReceive()
|
||||
* above. Creating a second row here would duplicate that receipt.
|
||||
*
|
||||
* Idempotent — a booking already unloaded via this listener, a retried
|
||||
* checkpoint, or the final-yard "Auto Unload" bulk action is left alone.
|
||||
*/
|
||||
@OnEvent('booking.unloadedAtYard')
|
||||
async handleBookingUnloadedAtYard(payload: {
|
||||
bookingId: string;
|
||||
tradeDirection: string | null;
|
||||
}): Promise<void> {
|
||||
if (payload.tradeDirection !== 'IMPORT' && payload.tradeDirection !== 'DOMESTIC') return;
|
||||
try {
|
||||
const existing = (
|
||||
await this.inventoryRepository.findAll({ where: { bookingId: payload.bookingId } })
|
||||
)[0];
|
||||
if (existing) return;
|
||||
|
||||
const [booking]: Array<{
|
||||
weight: string | null;
|
||||
freightType: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
customer: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT COALESCE(
|
||||
NULLIF(b.cargo_total_weight_vgm, 0),
|
||||
(SELECT SUM(bcu.vgm_tons)
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc2
|
||||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||||
) AS weight,
|
||||
b.freight_type AS "freightType",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
company.name AS customer
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[payload.bookingId],
|
||||
);
|
||||
if (!booking) return;
|
||||
|
||||
const allocated = await this.allocation.resolveLocation({
|
||||
freightType: booking.freightType,
|
||||
tradeDirection: payload.tradeDirection,
|
||||
cargoTypeCode: booking.cargoTypeCode,
|
||||
});
|
||||
const location = allocated ?? (await this.pickDefaultLocation());
|
||||
if (!location) {
|
||||
this.logger.warn(
|
||||
`Checkpoint auto-unload for booking ${payload.bookingId}: no warehouse/yard/zone configured`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const saved = await this.inventoryRepository.create({
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
bookingId: payload.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'UNLOADED',
|
||||
grnNumber: this.generateGrnNumber(payload.tradeDirection, payload.bookingId, now, booking.customer),
|
||||
arrivedAt: now,
|
||||
unloadedAt: now,
|
||||
notes:
|
||||
(allocated as { rule?: { name: string } | null } | null)?.rule
|
||||
? `Unloaded → ${(allocated as { path?: string | null }).path}`
|
||||
: 'Unloaded from arrived train (checkpoint auto-unload)',
|
||||
});
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: saved.warehouseId,
|
||||
description: 'Unloaded from arrived train (checkpoint auto-unload)',
|
||||
});
|
||||
if (Number(saved.weight) > 0) {
|
||||
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Checkpoint auto-unload inventory create failed for ${payload.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
|
||||
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
|
||||
@@ -2689,16 +2789,16 @@ export class WarehouseInventoryService {
|
||||
if (!bookingId) return;
|
||||
const [booking] = await this.dataSource.query(
|
||||
`SELECT reference,
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
const hasLastMile =
|
||||
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
|
||||
// service_types.includes_last_mile is NOT read here — every service type
|
||||
// ships with it true, so it can't distinguish EDR last-mile from self-haul.
|
||||
// The delivery address is the only per-booking record of that choice.
|
||||
const hasLastMile = Boolean(booking?.lastMileDeliveryAddress?.trim?.());
|
||||
if (!booking?.reference || !hasLastMile) return;
|
||||
await this.lastMileService.acceptBooking(booking.reference);
|
||||
}
|
||||
@@ -2809,6 +2909,10 @@ export class WarehouseInventoryService {
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
if (dto.bookingId && bookingDirection === 'EXPORT') {
|
||||
void this.notifyCarriageAcceptanceReady(dto.bookingId);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
@@ -3057,14 +3161,15 @@ export class WarehouseInventoryService {
|
||||
*/
|
||||
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
|
||||
const runner = manager ?? this.dataSource;
|
||||
// service_types.includes_last_mile is NOT read here — every service type
|
||||
// ships with it true, which made the second disjunct below unreachable and
|
||||
// this method effectively return true only from an assigned truck.
|
||||
const [row]: Array<{ ok: number }> = await runner.query(
|
||||
`SELECT 1 AS ok
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
AND (b.customer_truck_assigned_at IS NOT NULL
|
||||
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
|
||||
AND COALESCE(st.includes_last_mile, false) = false))`,
|
||||
OR NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL)`,
|
||||
[bookingId],
|
||||
);
|
||||
return Boolean(row);
|
||||
@@ -5978,6 +6083,36 @@ export class WarehouseInventoryService {
|
||||
return booking ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer their export carriage acceptance sheet is ready to
|
||||
* download from the portal. Export acceptance happens at the warehouse gate
|
||||
* (see BookingsService.carriageAcceptanceSheet) — the sheet is generatable
|
||||
* as soon as the cargo is received, no wagon allocation required, so this
|
||||
* fires right after receive, not at marshalling.
|
||||
*/
|
||||
private async notifyCarriageAcceptanceReady(bookingId: string): Promise<void> {
|
||||
try {
|
||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Carriage acceptance sheet ready',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, reference: b.reference },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyOwnerInventoryReceived(params: {
|
||||
phone?: string | null;
|
||||
ownerName?: string | null;
|
||||
|
||||
@@ -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 TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage";
|
||||
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||
@@ -411,6 +413,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "EDR Last Mile Returns",
|
||||
href: "/dashboard/edr-last-mile-returns",
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Container Returns",
|
||||
href: "/dashboard/container-returns",
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
@@ -1060,6 +1074,8 @@ const App = () => {
|
||||
<Route path="intercity" element={<IntercityPage />} />
|
||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
||||
<Route path="edr-last-mile-returns" element={<EDRLastMileReturnsPage />} />
|
||||
<Route path="container-returns" element={<ContainerReturnsPage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
@@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
import type { Company } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
companyName: "Company name",
|
||||
companyEmail: "Company email",
|
||||
companyPhone: "Company phone",
|
||||
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
function currentValue(company: Company, key: string): string {
|
||||
export function currentValue(company: Company, key: string): string {
|
||||
const c = company as unknown as Record<string, unknown>;
|
||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||
const map: Record<string, unknown> = {
|
||||
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
|
||||
);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
export function DiffRow({
|
||||
label,
|
||||
from,
|
||||
to,
|
||||
@@ -201,8 +200,9 @@ function DiffRow({
|
||||
|
||||
/**
|
||||
* Backoffice review surface for a customer's staged profile edits. Shows the
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject
|
||||
* (with note) actions, plus a short history of past decisions.
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject /
|
||||
* Request changes actions. Past decisions live in the History tab's unified
|
||||
* timeline (see {@link CompanyTimeline}), not here.
|
||||
*/
|
||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const { user } = useAuth();
|
||||
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const reject = useMutation(
|
||||
api.customers.rejectChangeRequest.mutationOptions(),
|
||||
);
|
||||
const requestChanges = useMutation(
|
||||
api.customers.requestChangeRequestChanges.mutationOptions(),
|
||||
);
|
||||
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||
const [actionTarget, setActionTarget] = useState<{
|
||||
id: string;
|
||||
kind: "reject" | "request-changes";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const requests = query.data ?? [];
|
||||
const pending = requests.find((r) => r.status === "pending");
|
||||
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
|
||||
|
||||
if (!pending && history.length === 0) return null;
|
||||
if (!pending) return null;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
const documentChanges = pending?.documentChanges ?? [];
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejectId) return;
|
||||
reject.mutate(
|
||||
{ id: rejectId, note: note.trim() },
|
||||
const confirmAction = () => {
|
||||
if (!actionTarget) return;
|
||||
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
|
||||
mutation.mutate(
|
||||
{ id: actionTarget.id, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRejectId(null);
|
||||
setActionTarget(null);
|
||||
setNote("");
|
||||
},
|
||||
},
|
||||
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{pending.note && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
>
|
||||
Changes were requested on an earlier round of this same
|
||||
submission: <strong>{pending.note}</strong> — check whether
|
||||
this resubmission actually addresses it before approving.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{proposedKeys.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{proposedKeys.map((key) => (
|
||||
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setActionTarget({ id: pending.id, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => {
|
||||
setActionTarget({ id: pending.id, kind: "request-changes" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} c="edr-text">
|
||||
Review history
|
||||
</Text>
|
||||
{history.map((r: CompanyChangeRequest) => (
|
||||
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
color={r.status === "approved" ? "edr-green" : "red"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
tt="capitalize"
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="edr-text">
|
||||
{formatDate(r.reviewedAt ?? r.updatedAt)}
|
||||
</Text>
|
||||
{r.note && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Note: {r.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={rejectId !== null}
|
||||
onClose={() => setRejectId(null)}
|
||||
title="Reject changes"
|
||||
opened={actionTarget !== null}
|
||||
onClose={() => setActionTarget(null)}
|
||||
title={
|
||||
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
||||
The customer will see this note and can amend and resubmit.
|
||||
<Alert
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
>
|
||||
{actionTarget?.kind === "reject"
|
||||
? "The customer will see this note and can amend and resubmit."
|
||||
: "The customer will see this note and can keep editing this same request — no need to start over."}
|
||||
</Alert>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
label={
|
||||
actionTarget?.kind === "reject"
|
||||
? "Reason for rejection"
|
||||
: "What needs to change"
|
||||
}
|
||||
placeholder="e.g. The company address doesn't match the trade license."
|
||||
autosize
|
||||
minRows={3}
|
||||
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectId(null)}
|
||||
disabled={reject.isPending}
|
||||
onClick={() => setActionTarget(null)}
|
||||
disabled={reject.isPending || requestChanges.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
loading={reject.isPending || requestChanges.isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
onClick={confirmAction}
|
||||
>
|
||||
Reject changes
|
||||
{actionTarget?.kind === "reject"
|
||||
? "Reject changes"
|
||||
: "Request changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { CompanyTimeline } from "./CompanyTimeline";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
type RequestDocumentChangeModalProps,
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
WAREHOUSE_ZONE_TYPES,
|
||||
WAREHOUSE_STATUSES,
|
||||
INVENTORY_STATUSES,
|
||||
type ImportUnloadedItem,
|
||||
type Warehouse,
|
||||
type WarehouseInventoryItem,
|
||||
type WarehouseYard,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
@@ -120,6 +122,44 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
|
||||
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* An unloaded-queue row seen as the inventory item `ReleaseOrderModal` expects.
|
||||
* Both truck-arrival openers (last mile, import trucks) work off queue rows.
|
||||
*/
|
||||
export const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
||||
({
|
||||
id: row.id,
|
||||
bookingId: row.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(row.weight) || 0,
|
||||
grnNumber: row.grnNumber,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||
// details captured at arrival (plate, driver, tare, gate-in).
|
||||
notes: row.notes,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: 'IMPORT',
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
||||
customerTruckDriverName: row.customerTruckDriverName,
|
||||
customerTruckType: row.customerTruckType,
|
||||
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
||||
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
||||
}
|
||||
: null,
|
||||
}) as unknown as WarehouseInventoryItem;
|
||||
|
||||
/**
|
||||
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
||||
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
||||
|
||||
@@ -42,6 +42,8 @@ export const QUERY_KEYS = {
|
||||
["customers", "detail", id, "reset-target"] as const,
|
||||
changeRequests: (id: string) =>
|
||||
["customers", "detail", id, "change-requests"] as const,
|
||||
revisions: (id: string) =>
|
||||
["customers", "detail", id, "revisions"] as const,
|
||||
},
|
||||
|
||||
INVOICES: {
|
||||
|
||||
@@ -78,10 +78,13 @@ export const URL_CONSTANTS = {
|
||||
`/companies/company-profiles/${profileId}/status`,
|
||||
CHANGE_REQUESTS: (companyId: string) =>
|
||||
`/companies/${companyId}/change-requests`,
|
||||
REVISIONS: (companyId: string) => `/companies/${companyId}/revisions`,
|
||||
CHANGE_REQUEST_APPROVE: (id: string) =>
|
||||
`/companies/change-requests/${id}/approve`,
|
||||
CHANGE_REQUEST_REJECT: (id: string) =>
|
||||
`/companies/change-requests/${id}/reject`,
|
||||
CHANGE_REQUEST_REQUEST_CHANGES: (id: string) =>
|
||||
`/companies/change-requests/${id}/request-changes`,
|
||||
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
|
||||
`/companies/documents/${fileId}/request-change`,
|
||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||
@@ -132,6 +135,8 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
|
||||
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
|
||||
`/bookings/${id}/carriage-acceptance-sheet`,
|
||||
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
||||
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
||||
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileSignature,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -269,6 +272,33 @@ export default function BookingRequestDetailPage() {
|
||||
View / sign contract
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<FileText size={16} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob =
|
||||
await bookingsService.downloadCarriageAcceptanceSheet(
|
||||
booking.id,
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `carriage-acceptance-${booking.reference}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Carriage acceptance sheet is not available yet",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Carriage acceptance sheet
|
||||
</Button>
|
||||
{booking.customsClearingEnabled && (
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
History,
|
||||
Hourglass,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
ChangeRequestPendingBadge,
|
||||
ChangeRequestReview,
|
||||
CompanyStatusBadge,
|
||||
CompanyTimeline,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
PaymentStatusBadge,
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
} from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
Company,
|
||||
CompanyProfile,
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
@@ -78,6 +81,32 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
|
||||
function downloadTinRecord(company: Company) {
|
||||
const lines = [
|
||||
`TIN: ${company.tin}`,
|
||||
`Company name: ${company.name}`,
|
||||
`Licence number: ${company.licenceNumber ?? ""}`,
|
||||
`Status: ${company.statusDescription ?? ""}`,
|
||||
`Date registered: ${company.dateRegistered ?? ""}`,
|
||||
`Renewed from: ${company.renewedFrom ?? ""}`,
|
||||
`Renewal date: ${company.renewalDate ?? ""}`,
|
||||
`Renewed to: ${company.renewedTo ?? ""}`,
|
||||
`Address: ${[company.region, company.zone, company.woreda, company.kebele, company.houseNo].filter(Boolean).join(", ")}`,
|
||||
];
|
||||
const blob = new Blob([lines.join("\n")], {
|
||||
type: "text/plain;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `tin-${company.tin}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
@@ -689,6 +718,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
@@ -757,6 +789,18 @@ export default function CustomerDetailPage() {
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField label="VAT number" value={company.vatNumber} />
|
||||
<InfoField label="FAN number" value={company.fanNumber} />
|
||||
<InfoField
|
||||
label="Submitted on"
|
||||
value={formatDate(company.createdAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Approved on"
|
||||
value={
|
||||
company.approvedAt
|
||||
? formatDate(company.approvedAt)
|
||||
: "Not yet approved"
|
||||
}
|
||||
/>
|
||||
<InfoField
|
||||
label="Owner identity"
|
||||
value={
|
||||
@@ -800,18 +844,29 @@ export default function CustomerDetailPage() {
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord && (
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
aria-label="Download TIN record"
|
||||
onClick={() => downloadTinRecord(company)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord ? (
|
||||
@@ -1235,6 +1290,11 @@ export default function CustomerDetailPage() {
|
||||
</Box>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* HISTORY */}
|
||||
<Tabs.Panel value="history" pt="lg">
|
||||
<CompanyTimeline company={company} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RequestDocumentChangeModal
|
||||
|
||||
@@ -245,6 +245,16 @@ export default function CustomersPage() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approved",
|
||||
header: "Approved",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -141,6 +141,7 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
required: true,
|
||||
description: "Pre-filled from the truck type — override only for a one-off",
|
||||
},
|
||||
{ name: "pricePerKm", label: "Price per KM (ETB)", type: "number", description: "Haulage rate charged per kilometre" },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
|
||||
{ name: "description", label: "Description", type: "textarea" },
|
||||
@@ -157,6 +158,7 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
year: new Date().getFullYear(),
|
||||
fuelType: "DIESEL",
|
||||
capacity: 0,
|
||||
pricePerKm: 0,
|
||||
status: "ACTIVE",
|
||||
availability: "FREE",
|
||||
description: "",
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { toReleaseInventoryItem } from "@/components/warehouses/options";
|
||||
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||
@@ -299,40 +300,6 @@ const requestedDate = (r: LastMileRecord) => {
|
||||
const serviceTypeName = (r: LastMileRecord) =>
|
||||
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
||||
|
||||
const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
||||
({
|
||||
id: row.id,
|
||||
bookingId: row.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(row.weight) || 0,
|
||||
grnNumber: row.grnNumber,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||
// details captured at arrival (plate, driver, tare, gate-in).
|
||||
notes: row.notes,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: "IMPORT",
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
||||
customerTruckDriverName: row.customerTruckDriverName,
|
||||
customerTruckType: row.customerTruckType,
|
||||
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
||||
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
||||
}
|
||||
: null,
|
||||
}) as unknown as WarehouseInventoryItem;
|
||||
|
||||
const releasePrefillFromLastMile = (
|
||||
record: LastMileRecord,
|
||||
row?: ImportUnloadedItem | null,
|
||||
|
||||
@@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
||||
{ value: "card", label: "Card" },
|
||||
{ value: "dmoney", label: "D-Money" },
|
||||
{ value: "cac-bank", label: "CAC Bank" },
|
||||
{ value: "cbe-bill", label: "CBE Bill" },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
|
||||
@@ -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 { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
Receipt,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -27,14 +28,22 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
|
||||
import { extractDownloadErrorMessage, formatNumber } from "@/components/warehouses/options";
|
||||
import {
|
||||
ReleaseOrderModal,
|
||||
type ReleaseOrderTruckPrefill,
|
||||
} from "@/components/warehouses/ReleaseOrderModal";
|
||||
import {
|
||||
extractDownloadErrorMessage,
|
||||
formatNumber,
|
||||
toReleaseInventoryItem,
|
||||
} from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import { lastMileService } from "@/services/last-mile.service";
|
||||
import { warehouseService, type LastMileArrivalTruck } from "@/services/warehouse.service";
|
||||
import type { ImportUnloadedItem } from "@/types/warehouse";
|
||||
import type { ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse";
|
||||
|
||||
/**
|
||||
* Import trucks — the unloaded queue seen truck-first instead of item-first.
|
||||
@@ -53,6 +62,20 @@ import type { ImportUnloadedItem } from "@/types/warehouse";
|
||||
|
||||
const COLS = 11;
|
||||
|
||||
/** Truck columns — the table head, and repeated inside each expanded booking. */
|
||||
const TRUCK_COLUMNS = [
|
||||
"Plate",
|
||||
"Type",
|
||||
"Containers",
|
||||
"Truck Arrival",
|
||||
"Truck Leaving",
|
||||
"Weight",
|
||||
"Demurrage",
|
||||
"Storage",
|
||||
"Detention",
|
||||
"Actions",
|
||||
] as const;
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||
|
||||
@@ -109,6 +132,8 @@ interface TruckRow {
|
||||
vehicleId: string | null;
|
||||
arrivedAt: string | null;
|
||||
departedAt: string | null;
|
||||
/** Identity handed to the release modal so it opens on THIS truck. */
|
||||
prefill: ReleaseOrderTruckPrefill;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,10 +142,17 @@ interface TruckRow {
|
||||
*/
|
||||
function TruckRows({ group }: { group: BookingGroup }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
const [detentionOpen, setDetentionOpen] = useState(false);
|
||||
const [gateTimesOpen, setGateTimesOpen] = useState(false);
|
||||
// The truck whose arrival/exit weighing is open — kept in state so the prefill
|
||||
// object stays referentially stable while the modal is up.
|
||||
const [release, setRelease] = useState<{
|
||||
item: WarehouseInventoryItem;
|
||||
prefill: ReleaseOrderTruckPrefill;
|
||||
} | null>(null);
|
||||
|
||||
const edrQuery = useQuery({
|
||||
queryKey: ["booking-edr-trucks", group.bookingId],
|
||||
@@ -194,6 +226,15 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
vehicleId: t.vehicleId,
|
||||
arrivedAt: t.arrivedAt,
|
||||
departedAt: t.departedAt,
|
||||
prefill: {
|
||||
truckPlateNumber: t.truckPlateNumber,
|
||||
trailerPlateNumber: t.trailerPlateNumber,
|
||||
driverName: t.driverName,
|
||||
driverLicense: t.driverLicense,
|
||||
driverPhone: t.driverPhone,
|
||||
truckType: t.truckType,
|
||||
containerNumber: t.containerNumber,
|
||||
},
|
||||
...costsFor(containers),
|
||||
};
|
||||
};
|
||||
@@ -208,11 +249,39 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
vehicleId: null,
|
||||
arrivedAt: t.arrivedAt ?? null,
|
||||
departedAt: t.departedAt ?? null,
|
||||
prefill: {
|
||||
truckPlateNumber: t.plateNumber,
|
||||
driverName: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containerNumber: containers.join(", "),
|
||||
},
|
||||
...costsFor(containers),
|
||||
};
|
||||
};
|
||||
const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer);
|
||||
|
||||
/**
|
||||
* Arrival and leaving are one form per truck: the modal picks the step from
|
||||
* that plate's own saved weighing block, so both menu items open it the same
|
||||
* way. The booking-level opener (inventory workbench) stays as it was.
|
||||
*/
|
||||
const openRelease = (t: TruckRow) => {
|
||||
const row = group.rows.find((r) => r.id === t.inventoryIds[0]) ?? group.rows[0];
|
||||
if (!row) return;
|
||||
setRelease({ item: toReleaseInventoryItem(row), prefill: t.prefill });
|
||||
};
|
||||
|
||||
const closeRelease = () => {
|
||||
setRelease(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["booking-edr-trucks", group.bookingId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["booking-customer-trucks", group.bookingId] });
|
||||
// The saved weighing block lives in the inventory row's notes — refetch the
|
||||
// queue or the next open would still show the truck as never arrived.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.warehouses.importUnloadedQueue.queryOptions({}).queryKey,
|
||||
});
|
||||
};
|
||||
|
||||
const openDocument = async (
|
||||
kind: "release" | "handover",
|
||||
inventoryId: string,
|
||||
@@ -267,6 +336,18 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The booking row sits between the head and these trucks, so repeat the
|
||||
column labels — otherwise an expanded booking reads as unlabelled. */}
|
||||
<Table.Tr bg="var(--mantine-color-gray-1)">
|
||||
<Table.Td />
|
||||
{TRUCK_COLUMNS.map((label) => (
|
||||
<Table.Td key={label} ta={label === "Actions" ? "right" : undefined}>
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
{trucks.map((t, idx) => {
|
||||
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
|
||||
const primaryId = t.inventoryIds[0] ?? null;
|
||||
@@ -337,6 +418,21 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => openRelease(t)}
|
||||
>
|
||||
Truck Arrival
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => openRelease(t)}
|
||||
>
|
||||
Truck Leaving
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={14} />}
|
||||
disabled={!primaryId}
|
||||
@@ -388,6 +484,12 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
onClose={() => setInspectId(null)}
|
||||
inventoryId={inspectId}
|
||||
/>
|
||||
<ReleaseOrderModal
|
||||
opened={Boolean(release)}
|
||||
onClose={closeRelease}
|
||||
item={release?.item ?? null}
|
||||
truckPrefill={release?.prefill ?? null}
|
||||
/>
|
||||
{isEdr && (
|
||||
<>
|
||||
<TruckDetentionModal
|
||||
@@ -459,16 +561,11 @@ export default function ImportTrucksPage() {
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th>Truck Arrival</Table.Th>
|
||||
<Table.Th>Truck Leaving</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Demurrage</Table.Th>
|
||||
<Table.Th>Storage</Table.Th>
|
||||
<Table.Th>Detention</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
{TRUCK_COLUMNS.map((label) => (
|
||||
<Table.Th key={label} ta={label === "Actions" ? "right" : undefined}>
|
||||
{label}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
|
||||
@@ -150,8 +150,9 @@ function AllocationRules() {
|
||||
.filter((yard) => yard.code)
|
||||
.map((yard) => ({
|
||||
value: yard.code,
|
||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||
label: `${yard.name} (${yard.code})${yard.warehouse?.code ? ` — ${yard.warehouse.code}` : ''}`,
|
||||
}));
|
||||
const yardNameByCode = Object.fromEntries(yards.map((yard) => [yard.code, yard.name]));
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
@@ -223,7 +224,11 @@ function AllocationRules() {
|
||||
{
|
||||
id: 'targetYard',
|
||||
header: 'Target yard',
|
||||
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light">
|
||||
{yardNameByCode[row.original.targetYardCode] ?? row.original.targetYardCode}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
@@ -307,7 +312,8 @@ function AllocationRules() {
|
||||
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
|
||||
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
|
||||
{form.containerStatus.trim() ? ` and container status ${form.containerStatus.trim()}` : ''}{' '}
|
||||
is received, <b>send it to</b> {form.targetYardCode || 'a selected target yard'}.
|
||||
is received, <b>send it to</b>{' '}
|
||||
{(form.targetYardCode && yardNameByCode[form.targetYardCode]) || form.targetYardCode || 'a selected target yard'}.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CompanyChangeRequest,
|
||||
CompanyListFilter,
|
||||
CompanyProfile,
|
||||
CompanyRevision,
|
||||
CompanyStats,
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
@@ -2778,6 +2779,13 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
|
||||
),
|
||||
|
||||
revisions: endpoint<{ id: string }, CompanyRevision[]>(
|
||||
"customers",
|
||||
"revisions",
|
||||
({ id }) => customersService.revisions(id),
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.revisions(id),
|
||||
),
|
||||
|
||||
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
|
||||
"customers",
|
||||
"approveChangeRequest",
|
||||
@@ -2802,6 +2810,21 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
requestChangeRequestChanges: endpoint<
|
||||
{ id: string; note: string },
|
||||
CompanyChangeRequest
|
||||
>(
|
||||
"customers",
|
||||
"requestChangeRequestChanges",
|
||||
({ id, note }) => customersService.requestChangeRequestChanges(id, note),
|
||||
undefined,
|
||||
(_input, data) => [
|
||||
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||
],
|
||||
),
|
||||
|
||||
/**
|
||||
* Ask the customer to correct one document. Invalidates the documents list
|
||||
* and the company itself, since an open request blocks role approval.
|
||||
|
||||
@@ -455,6 +455,13 @@ export const bookingsService = {
|
||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||
},
|
||||
|
||||
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
|
||||
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
|
||||
responseType: "blob",
|
||||
});
|
||||
return ensurePdfBlob(response.data as Blob);
|
||||
},
|
||||
|
||||
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
|
||||
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
|
||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CompanyChangeRequest,
|
||||
CompanyListFilter,
|
||||
CompanyProfile,
|
||||
CompanyRevision,
|
||||
CompanyStats,
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
@@ -146,6 +147,13 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Onboarding-phase edit history for a company (version history), newest first. */
|
||||
revisions(companyId: string): Promise<CompanyRevision[]> {
|
||||
return apiClient
|
||||
.get<CompanyRevision[]>(URL_CONSTANTS.COMPANIES.REVISIONS(companyId))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Approve a pending change request — applies the proposed changes. */
|
||||
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
|
||||
return apiClient
|
||||
@@ -165,6 +173,19 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Ask for specific changes without rejecting — the request stays open for the customer's next edit to append to. */
|
||||
requestChangeRequestChanges(
|
||||
id: string,
|
||||
note: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
return apiClient
|
||||
.post<CompanyChangeRequest>(
|
||||
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REQUEST_CHANGES(id),
|
||||
{ note },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the customer to correct one uploaded document. Narrower than rejecting
|
||||
* the whole role: the customer keeps their other documents and only re-uploads
|
||||
|
||||
@@ -19,7 +19,8 @@ export type PaymentMethod =
|
||||
| "waafi"
|
||||
| "card"
|
||||
| "dmoney"
|
||||
| "cac-bank";
|
||||
| "cac-bank"
|
||||
| "cbe-bill";
|
||||
|
||||
export interface PaymentRow {
|
||||
id: string;
|
||||
|
||||
@@ -69,7 +69,11 @@ export interface CompanyProfile {
|
||||
}
|
||||
|
||||
/** Lifecycle of a staged customer profile-edit review. */
|
||||
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
|
||||
export type ChangeRequestStatus =
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "changes_requested";
|
||||
|
||||
/** A staged business-license add/remove on one profile, awaiting review. */
|
||||
export interface LicenseChangeIntent {
|
||||
@@ -110,6 +114,33 @@ export interface CompanyChangeRequest {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One field/document change recorded on a company revision. A document change
|
||||
* carries `fromFileId`/`toFileId` alongside the display names, so the
|
||||
* previous and current file can both be opened, not just named.
|
||||
*/
|
||||
export interface CompanyRevisionChange {
|
||||
field: string;
|
||||
label: string;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
fromFileId?: string | null;
|
||||
toFileId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding-phase edit history — records what changed on a company record
|
||||
* before it reached Active, the write path that has no approval gate.
|
||||
*/
|
||||
export interface CompanyRevision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorId: string | null;
|
||||
summary: string;
|
||||
changes: CompanyRevisionChange[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The channel a customer's password-reset link is delivered over. */
|
||||
export type ResetChannel = "email" | "phone";
|
||||
|
||||
@@ -215,6 +246,7 @@ export interface Company {
|
||||
onboardingCompleted?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
approvedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -883,6 +883,9 @@ export function AppLayout({
|
||||
<AppShell.Main
|
||||
style={{
|
||||
backgroundColor: "##f8fafc",
|
||||
// Extra bottom clearance so the fixed support-chat FAB never
|
||||
// overlaps page content, even at the bottom of a scrolled page.
|
||||
paddingBottom: 112,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -28,6 +28,31 @@ interface ETradeInfoProps {
|
||||
|
||||
const isValidTin = (tin: string) => tin.length === 10;
|
||||
|
||||
/** Plain-text summary of the fetched eTrade record, downloaded client-side (eTrade returns data, not a document). */
|
||||
function downloadTinRecord(tin: string, data: CompanyRegistrationData) {
|
||||
const lines = [
|
||||
`TIN: ${tin}`,
|
||||
`Company name: ${data.companyName}`,
|
||||
`Licence number: ${data.licenceNumber}`,
|
||||
`Status: ${data.statusDescription}`,
|
||||
`Date registered: ${data.dateRegistered}`,
|
||||
`Renewed from: ${data.renewedFrom}`,
|
||||
`Renewal date: ${data.renewalDate}`,
|
||||
`Renewed to: ${data.renewedTo}`,
|
||||
`Address: ${[data.region, data.zone, data.woreda, data.kebele, data.houseNo].filter(Boolean).join(", ")}`,
|
||||
`Manager: ${data.managerName}`,
|
||||
];
|
||||
const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `tin-${tin}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
export default function ETradeInfo({
|
||||
tin,
|
||||
register,
|
||||
@@ -123,6 +148,17 @@ export default function ETradeInfo({
|
||||
{isLoading ? "Getting..." : "Get Data"}
|
||||
</Button>
|
||||
)}
|
||||
{status === "verified" && mutation.data && !mutation.data.tinTaken && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
onClick={() => downloadTinRecord(tin, mutation.data!)}
|
||||
leftSection={<Download size={16} />}
|
||||
mt="24px"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{notFound && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, CheckCircle2, Clock } from "lucide-react";
|
||||
import { api } from "@/services/api";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { OnboardingRequirements } from "@/services/companies.service";
|
||||
@@ -150,18 +150,46 @@ export default function OnboardingResumeBanner({
|
||||
|
||||
/**
|
||||
* Post-onboarding review banner. Surfaces (in priority order):
|
||||
* 0. The account is suspended/blacklisted — hard lock.
|
||||
* 1. A pending profile-edit review — the whole account is locked until an admin
|
||||
* approves the submitted changes.
|
||||
* 2. A rejected profile-edit review — links to Settings to amend & resubmit.
|
||||
* 3. Per-operational-profile approval — bookings unlock as each role clears.
|
||||
* 2. Backoffice requested changes — soft: same edit-and-resubmit call to
|
||||
* action as a rejection, but the edit appends to the same request.
|
||||
* 3. A rejected profile-edit review — links to Settings to amend & resubmit
|
||||
* (starts a fresh request).
|
||||
* 4/5. Company- and per-operational-profile approval — bookings unlock as
|
||||
* each role clears.
|
||||
* Self-hides when there's nothing outstanding.
|
||||
*/
|
||||
export function AccountReviewBanner() {
|
||||
const { company, reviewStatus, reviewNote } = useAuth();
|
||||
const { company, companyStatus, reviewStatus, reviewNote } = useAuth();
|
||||
const profiles = company?.company?.companyProfiles ?? [];
|
||||
const pending = profiles.filter((p) => p.status === "pending");
|
||||
const approved = profiles.filter((p) => p.status === "active");
|
||||
|
||||
// 0. Account suspended/blacklisted — the hardest lock, takes priority over
|
||||
// everything else since nothing below matters if the account is shut down.
|
||||
if (companyStatus === "suspended" || companyStatus === "blacklisted") {
|
||||
return (
|
||||
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-700">
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-semibold text-red-900">
|
||||
Your account has been suspended
|
||||
</span>
|
||||
<span className="text-xs text-red-800">
|
||||
Contact EDR support to resolve this before you can continue
|
||||
working.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Profile-edit review pending — the account-wide lock.
|
||||
if (reviewStatus === "pending") {
|
||||
return (
|
||||
@@ -184,7 +212,41 @@ export function AccountReviewBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Profile-edit review rejected — prompt to fix & resubmit.
|
||||
// 2. Backoffice asked for specific changes — soft: same edit-and-resubmit
|
||||
// call to action as a rejection, but the copy stays collaborative since
|
||||
// the edit appends to this same request instead of starting over.
|
||||
if (reviewStatus === "changes_requested") {
|
||||
return (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-semibold text-amber-900">
|
||||
Changes requested on your submission
|
||||
</span>
|
||||
<span className="text-xs text-amber-800">
|
||||
{reviewNote
|
||||
? `Reviewer note: ${reviewNote}`
|
||||
: "Please update the requested details and resubmit for review."}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
Review & resubmit
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Profile-edit review rejected — prompt to fix & resubmit.
|
||||
if (reviewStatus === "rejected") {
|
||||
return (
|
||||
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
|
||||
@@ -216,8 +278,45 @@ export function AccountReviewBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Per-operational-profile approval (existing behaviour).
|
||||
if (profiles.length === 0 || pending.length === 0) return null;
|
||||
// 4. Company approved and awaiting its first operational profile — nothing
|
||||
// profile-specific to report yet, but the account itself is pending.
|
||||
if (profiles.length === 0) {
|
||||
if (companyStatus === "pending") {
|
||||
return (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-amber-900">
|
||||
Your account is pending approval
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. Per-operational-profile approval (existing behaviour).
|
||||
if (pending.length === 0) {
|
||||
// Nothing outstanding — a quiet confirmation that the account is live.
|
||||
if (companyStatus === "active") {
|
||||
return (
|
||||
<div className="border-b border-emerald-200 bg-emerald-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<CheckCircle2 size={18} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-emerald-900">
|
||||
Your account is approved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingLabel = pending
|
||||
.map((p) => p.type.replace(/_/g, " "))
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -127,6 +128,7 @@ export default function OnboardingWizardDialog({
|
||||
}: OnboardingWizardDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
|
||||
const existingProfiles = company?.company?.companyProfiles ?? [];
|
||||
const companyAlreadyStarted = Boolean(company?.company?.id);
|
||||
@@ -409,7 +411,9 @@ export default function OnboardingWizardDialog({
|
||||
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
|
||||
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
|
||||
const effectiveResumeStep: FormStep =
|
||||
FORM_STEPS[Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)];
|
||||
FORM_STEPS[
|
||||
Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)
|
||||
];
|
||||
|
||||
const formProps = {
|
||||
documentSettingCode: resolvedDocumentSettingCode,
|
||||
@@ -450,9 +454,10 @@ export default function OnboardingWizardDialog({
|
||||
withCloseButton={!completed}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={!completed}
|
||||
fullScreen={isMobile}
|
||||
size={1440}
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
padding={isMobile ? "md" : "xl"}
|
||||
centered
|
||||
keepMounted
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
@@ -464,6 +469,9 @@ export default function OnboardingWizardDialog({
|
||||
title: {
|
||||
flex: 1,
|
||||
},
|
||||
body: isMobile
|
||||
? { paddingBottom: "calc(100px + env(safe-area-inset-bottom))" }
|
||||
: undefined,
|
||||
}}
|
||||
title={
|
||||
completed ? null : (
|
||||
|
||||
@@ -130,6 +130,8 @@ export const URL_CONSTANTS = {
|
||||
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
|
||||
CUSTOMER_TRUCKS_BULK: (id: string) =>
|
||||
`/api/bookings/${id}/customer-trucks/bulk`,
|
||||
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
|
||||
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
|
||||
},
|
||||
@@ -201,6 +203,8 @@ export const URL_CONSTANTS = {
|
||||
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
|
||||
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
|
||||
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||
CONFIRM_INVOICE_OTP: (id: string) =>
|
||||
`/api/billing/my-invoices/${id}/confirm`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
|
||||
@@ -41,7 +41,7 @@ export function SupportWidget() {
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<Affix position={{ bottom: 24, right: 24 }} zIndex={300}>
|
||||
<Affix position={{ bottom: 24, right: 24 }} zIndex={300} className="group">
|
||||
<Transition mounted={open} transition="pop-bottom-right" duration={200}>
|
||||
{(styles) => (
|
||||
<div style={styles} className="mb-3">
|
||||
@@ -53,40 +53,44 @@ export function SupportWidget() {
|
||||
<Transition mounted={!open} transition="pop" duration={150}>
|
||||
{(styles) => (
|
||||
<div style={styles} className="flex justify-end">
|
||||
<Indicator
|
||||
label={unread > 9 ? "9+" : unread}
|
||||
size={20}
|
||||
offset={8}
|
||||
color="red"
|
||||
disabled={unread === 0}
|
||||
processing
|
||||
withBorder
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open support chat"
|
||||
onClick={() => setOpen(true)}
|
||||
className="group relative flex cursor-pointer items-center gap-2.5 rounded-full bg-linear-135 from-edr-primary-dark to-edr-primary py-2 pr-2 pl-2 text-white shadow-[0_10px_28px_rgba(13,92,44,0.4)] transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-edr-primary active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:pr-5"
|
||||
{/* Icon+label stay recognizable at rest, just tucked down a bit;
|
||||
lifts fully into place on hover/focus. */}
|
||||
<div className="translate-y-[60%] transition-transform duration-300 ease-out group-hover:translate-y-0 focus-within:translate-y-0 motion-reduce:translate-y-0">
|
||||
<Indicator
|
||||
label={unread > 9 ? "9+" : unread}
|
||||
size={20}
|
||||
offset={8}
|
||||
color="red"
|
||||
disabled={unread === 0}
|
||||
processing
|
||||
withBorder
|
||||
>
|
||||
{/* Faint breathing ring; the unread badge already pulses, so stand down then. */}
|
||||
{unread === 0 && (
|
||||
<span className="pointer-events-none absolute -inset-1 animate-pulse rounded-full ring-2 ring-edr-primary/50 motion-reduce:animate-none" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open support chat"
|
||||
onClick={() => setOpen(true)}
|
||||
className="group relative flex cursor-pointer items-center gap-2.5 rounded-full bg-linear-135 from-edr-primary-dark to-edr-primary py-2 pr-2 pl-2 text-white shadow-[0_10px_28px_rgba(13,92,44,0.4)] transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-edr-primary active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:pr-5"
|
||||
>
|
||||
{/* Faint breathing ring; the unread badge already pulses, so stand down then. */}
|
||||
{unread === 0 && (
|
||||
<span className="pointer-events-none absolute -inset-1 animate-pulse rounded-full ring-2 ring-edr-primary/50 motion-reduce:animate-none" />
|
||||
)}
|
||||
|
||||
<span className="relative grid size-[42px] shrink-0 place-items-center rounded-full bg-white/20">
|
||||
<Headset size={22} />
|
||||
</span>
|
||||
<span className="relative grid size-[42px] shrink-0 place-items-center rounded-full bg-white/20">
|
||||
<Headset size={22} />
|
||||
</span>
|
||||
|
||||
<span className="relative hidden text-left leading-tight sm:block">
|
||||
<span className="block text-sm font-semibold whitespace-nowrap">
|
||||
👋 Need help?
|
||||
<span className="relative hidden text-left leading-tight sm:block">
|
||||
<span className="block text-sm font-semibold whitespace-nowrap">
|
||||
👋 Need help?
|
||||
</span>
|
||||
<span className="block text-[11px] whitespace-nowrap opacity-85">
|
||||
Chat with our team
|
||||
</span>
|
||||
</span>
|
||||
<span className="block text-[11px] whitespace-nowrap opacity-85">
|
||||
Chat with our team
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Indicator>
|
||||
</button>
|
||||
</Indicator>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
PackagePlus,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
@@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
||||
isPayable(inv.status),
|
||||
)?.id;
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payableInvoiceId) {
|
||||
throw new Error(
|
||||
"No payable invoice found for this booking yet. Please refresh or contact support.",
|
||||
);
|
||||
}
|
||||
return api.invoices.pay.call({
|
||||
id: payableInvoiceId,
|
||||
payload: { method, platform: "web" },
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
const url =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({
|
||||
invoiceId: payableInvoiceId!,
|
||||
method,
|
||||
});
|
||||
window.location.href = url;
|
||||
},
|
||||
});
|
||||
const pay = useInvoicePayment();
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
@@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
||||
<PaymentMethodModal
|
||||
opened={payItem !== null}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
if (!pay.processing) {
|
||||
setPayItem(null);
|
||||
payMutation.reset();
|
||||
pay.reset();
|
||||
}
|
||||
}}
|
||||
currency={undefined}
|
||||
processing={payMutation.isPending}
|
||||
processing={pay.processing}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
pay.error ??
|
||||
(payItemInvoices.length > 0 && !payableInvoiceId
|
||||
? "No payable invoice found for this booking yet. Please refresh or contact support."
|
||||
: null)
|
||||
}
|
||||
otp={pay.otp}
|
||||
onConfirm={(method, payerAccount) =>
|
||||
payableInvoiceId &&
|
||||
pay.pay(payableInvoiceId, method, payerAccount)
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
</ModalSafeWrapper>
|
||||
</Card>
|
||||
|
||||
@@ -287,9 +287,31 @@ export default function SettingsPage() {
|
||||
icon={<Clock size={18} />}
|
||||
title="Changes submitted for review"
|
||||
>
|
||||
Your recent changes are awaiting administrator approval. Editing is
|
||||
disabled until the review is complete — you'll be notified once it's
|
||||
approved or if any changes are requested.
|
||||
Your recent changes are awaiting administrator approval. Company
|
||||
details and documents can't be edited until the review is complete —
|
||||
you'll be notified once it's approved or if any changes are
|
||||
requested. Your contact person, general manager and Power of
|
||||
Attorney stay editable.
|
||||
</Alert>
|
||||
)}
|
||||
{reviewStatus === "changes_requested" && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Changes requested on your submission"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{profile.reviewNote && (
|
||||
<Text size="sm">
|
||||
<strong>Reviewer note:</strong> {profile.reviewNote}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm">
|
||||
Please update the requested details below and save again to
|
||||
resubmit for review.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{reviewStatus === "rejected" && (
|
||||
@@ -358,9 +380,17 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* While a change request is pending, every panel's inputs + submit
|
||||
buttons are disabled via the native fieldset; tab switching stays
|
||||
enabled so the customer can still review what they submitted. */}
|
||||
{/* While a change request is pending, the reviewed panels' inputs +
|
||||
submit buttons are disabled via the native fieldset; tab switching
|
||||
stays enabled so the customer can still review what they
|
||||
submitted.
|
||||
|
||||
Personnel panels below (contact person, general manager, Power of
|
||||
Attorney) are deliberately outside the lock: the API applies those
|
||||
edits live rather than staging them, so locking them here would
|
||||
re-impose the approval wait the API no longer does. The PoA's
|
||||
delegation letter is still reviewed — that lock lives on the file
|
||||
itself, not the panel. */}
|
||||
<Tabs.Panel value="company">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
||||
@@ -368,19 +398,13 @@ export default function SettingsPage() {
|
||||
<OperationalServicesCard profile={profile} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="contact">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabContactPerson profile={profile} mode="edit" />
|
||||
</Fieldset>
|
||||
<TabContactPerson profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="gm">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabGeneralManager profile={profile} mode="edit" />
|
||||
</Fieldset>
|
||||
<TabGeneralManager profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="poa">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||
</Fieldset>
|
||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="documents">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
buildOnboardingSchema,
|
||||
type CompanyStep,
|
||||
type FormData,
|
||||
hasPoaDetails,
|
||||
POA_DELEGATION_FILE_KEY,
|
||||
stepFields,
|
||||
} from "./companyProfileForm/schema";
|
||||
@@ -191,11 +190,7 @@ export default function CompanyProfileForm({
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(
|
||||
buildOnboardingSchema(
|
||||
requirePoa,
|
||||
verifiedIdentity,
|
||||
identity?.passportRequired === true,
|
||||
),
|
||||
buildOnboardingSchema(identity?.passportRequired === true),
|
||||
),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
@@ -321,7 +316,6 @@ export default function CompanyProfileForm({
|
||||
// them and re-enables editing.
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||
|
||||
// General Manager source. The company step's email/phone are seeded from
|
||||
// eTrade (and the account email) but stay editable, so the link reads the
|
||||
@@ -367,9 +361,6 @@ export default function CompanyProfileForm({
|
||||
const gmName = watch("generalManagerName");
|
||||
const gmEmail = watch("generalManagerEmail");
|
||||
const gmPhone = watch("generalManagerPhone");
|
||||
const contactName = watch("contactPersonName");
|
||||
const contactEmail = watch("contactPersonEmail");
|
||||
const contactPhone = watch("contactPersonPhone");
|
||||
|
||||
// While linked, mirror the source values into the (disabled) target fields so
|
||||
// the copy stays current even if the user goes back and edits the source.
|
||||
@@ -381,26 +372,6 @@ export default function CompanyProfileForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||
|
||||
// The contact-person step has no address of its own, so the linked PoA takes
|
||||
// the company's composed address. poaLocation (the city) stays typed on the
|
||||
// PoA step — the company step no longer has a location field to mirror.
|
||||
const companyAddress = watch("companyAddress");
|
||||
|
||||
useEffect(() => {
|
||||
if (!poaSameAsContact) return;
|
||||
setValue("poaName", contactName ?? "");
|
||||
setValue("poaEmail", contactEmail ?? "");
|
||||
setValue("poaPhone", contactPhone ?? "");
|
||||
setValue("poaAddress", companyAddress ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
poaSameAsContact,
|
||||
contactName,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
companyAddress,
|
||||
]);
|
||||
|
||||
const toggleContactSameAsGm = (checked: boolean) => {
|
||||
setContactSameAsGm(checked);
|
||||
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
||||
@@ -411,17 +382,6 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
};
|
||||
|
||||
const togglePoaSameAsContact = (checked: boolean) => {
|
||||
setPoaSameAsContact(checked);
|
||||
if (!checked) {
|
||||
setValue("poaName", "");
|
||||
setValue("poaEmail", "");
|
||||
setValue("poaPhone", "");
|
||||
setValue("poaLocation", "");
|
||||
setValue("poaAddress", "");
|
||||
}
|
||||
};
|
||||
|
||||
// The DARS delegation paper ships in the same nationality document set as the
|
||||
// rest (the API guarantees it is there), but belongs on the PoA step next to
|
||||
// the details it evidences — so it's split out here and the Documents step
|
||||
@@ -551,7 +511,9 @@ export default function CompanyProfileForm({
|
||||
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
|
||||
// the same rule on save, so skipping it here only costs the customer a
|
||||
// round-trip.
|
||||
const poaProvided = hasPoaDetails(watch());
|
||||
// A PoA exists exactly when one has been verified — the details are the
|
||||
// verification's output, so there is nothing else that could stand for one.
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const delegationRequired = requirePoa || poaProvided;
|
||||
const delegationPresent =
|
||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||
@@ -638,12 +600,7 @@ export default function CompanyProfileForm({
|
||||
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
step === "poa" &&
|
||||
verifiedIdentity &&
|
||||
requirePoa &&
|
||||
!identity?.poa.verified
|
||||
) {
|
||||
if (step === "poa" && requirePoa && !identity?.poa.verified) {
|
||||
setSaveError(
|
||||
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
||||
);
|
||||
@@ -876,71 +833,27 @@ export default function CompanyProfileForm({
|
||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
||||
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
|
||||
</Text>
|
||||
{/* A representative acts for the company inside Ethiopia
|
||||
whoever owns it, so the PoA is proven with Fayda regardless of
|
||||
nationality — their name, email, phone and address all come
|
||||
from the verification and are never typed here. */}
|
||||
{identity && (
|
||||
<FaydaVerifyPanel
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={identity.faydaRequired}
|
||||
required={requirePoa}
|
||||
onVerified={() => onIdentityChange?.()}
|
||||
/>
|
||||
)}
|
||||
{!verifiedIdentity && watch("contactPersonName") && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsContact}
|
||||
onToggle={togglePoaSameAsContact}
|
||||
title="Same as contact person"
|
||||
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
{!verifiedIdentity && (
|
||||
<>
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
{/* The city is the one field the Fayda address claim does not
|
||||
reliably decompose into, so it stays typed either way. */}
|
||||
{verifiedIdentity && (
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
)}
|
||||
reliably decompose into, so it stays typed. */}
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
|
||||
{poaDocumentSetting && (
|
||||
<>
|
||||
|
||||
@@ -37,10 +37,8 @@ export function buildPayload(
|
||||
generalManagerName: data.generalManagerName,
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: data.generalManagerPhone,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
// The representative's own details are written by their Fayda
|
||||
// verification, so the city is all the form has to send.
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
},
|
||||
};
|
||||
@@ -88,13 +86,7 @@ export function stepPayload(
|
||||
contactPersonPhone: d.contactPersonPhone,
|
||||
};
|
||||
case "poa":
|
||||
return {
|
||||
poaName: d.poaName || undefined,
|
||||
poaPhone: d.poaPhone || undefined,
|
||||
poaEmail: d.poaEmail || undefined,
|
||||
poaLocation: d.poaLocation || undefined,
|
||||
poaAddress: d.poaAddress || undefined,
|
||||
};
|
||||
return { poaLocation: d.poaLocation || undefined };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -92,58 +92,28 @@ export type FormData = z.infer<typeof onboardingSchema>;
|
||||
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
|
||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||
|
||||
export const POA_FIELDS = [
|
||||
"poaName",
|
||||
"poaPhone",
|
||||
"poaEmail",
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const satisfies readonly (keyof FormData)[];
|
||||
|
||||
/** True once the customer has entered any Power of Attorney detail. */
|
||||
export const hasPoaDetails = (d: Partial<FormData>) =>
|
||||
POA_FIELDS.some((f) => d[f]?.trim());
|
||||
|
||||
/**
|
||||
* A freight forwarder acts on other companies' behalf, so its PoA is mandatory
|
||||
* rather than optional. Everyone else keeps the optional PoA — but once they
|
||||
* start filling it in, the identifying fields have to be complete (the
|
||||
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
|
||||
* since files live outside the form state).
|
||||
* The PoA's identifying fields are never typed — they come from the Fayda
|
||||
* verification, whatever the company's nationality — so nothing here requires
|
||||
* them. A freight forwarder's mandatory PoA is gated on the verification
|
||||
* itself, and its delegation letter alongside it, both in CompanyProfileForm
|
||||
* (files live outside form state).
|
||||
*
|
||||
* That leaves the owner's passport number as the only conditional field.
|
||||
*/
|
||||
export function buildOnboardingSchema(
|
||||
requirePoa: boolean,
|
||||
/**
|
||||
* True when the PoA's identity fields come from a Fayda verification rather
|
||||
* than the form (Ethiopian companies). Requiring them here would fail
|
||||
* validation against inputs the step no longer renders — the verification
|
||||
* itself is what the step gates on instead.
|
||||
*/
|
||||
faydaOwnedPoa = false,
|
||||
/** True for a foreign company: the owner's passport number is mandatory. */
|
||||
passportRequired = false,
|
||||
) {
|
||||
const poaRequired = requirePoa && !faydaOwnedPoa;
|
||||
if (!poaRequired && !passportRequired) return onboardingSchema;
|
||||
if (!passportRequired) return onboardingSchema;
|
||||
return onboardingSchema.superRefine((d, ctx) => {
|
||||
const required: [keyof FormData, string][] = [];
|
||||
if (poaRequired) {
|
||||
required.push(
|
||||
["poaName", "PoA name is required for freight forwarders"],
|
||||
["poaEmail", "PoA email is required for freight forwarders"],
|
||||
["poaPhone", "PoA phone is required for freight forwarders"],
|
||||
);
|
||||
}
|
||||
if (passportRequired) {
|
||||
required.push([
|
||||
"ownerPassportNumber",
|
||||
"The owner's passport number is required",
|
||||
]);
|
||||
}
|
||||
for (const [path, message] of required) {
|
||||
if (!d[path]?.trim()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
|
||||
}
|
||||
if (!d.ownerPassportNumber?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["ownerPassportNumber"],
|
||||
message: "The owner's passport number is required",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -180,7 +150,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
],
|
||||
poa: [...POA_FIELDS],
|
||||
poa: ["poaLocation"],
|
||||
documents: [],
|
||||
additional: [],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -27,10 +28,7 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import {
|
||||
paymentsService,
|
||||
type PaymentMethod,
|
||||
} from "@/services/payments.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
@@ -72,21 +70,17 @@ export default function InvoiceDetailPage() {
|
||||
} = useQuery(api.invoices.get.queryOptions({ input: { id } }));
|
||||
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
// CBE bill payment: the bill reference to pay at any CBE channel (no redirect).
|
||||
const [billAction, setBillAction] = useState<{
|
||||
billReference?: string;
|
||||
instructions?: string;
|
||||
expiresAt?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
|
||||
// one of the signed-in customer's own invoices (unlike the admin-facing
|
||||
// /payments/initiate, which takes any invoiceId with no ownership check).
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) =>
|
||||
api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({ invoiceId: id, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
const pay = useInvoicePayment();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -249,7 +243,7 @@ export default function InvoiceDetailPage() {
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
loading={payMutation.isPending}
|
||||
loading={pay.processing}
|
||||
onClick={handlePay}
|
||||
styles={{
|
||||
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
||||
@@ -376,23 +370,76 @@ export default function InvoiceDetailPage() {
|
||||
<PaymentMethodModal
|
||||
opened={payModalOpen}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
if (!pay.processing) {
|
||||
setPayModalOpen(false);
|
||||
payMutation.reset();
|
||||
pay.reset();
|
||||
}
|
||||
}}
|
||||
amountLabel={formatCurrency(amountDue, invoice.currency)}
|
||||
currency={invoice.currency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
onConfirm={(method, payerAccount) =>
|
||||
pay.pay(id, method, payerAccount)
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
|
||||
{/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */}
|
||||
<Modal
|
||||
opened={!!billAction}
|
||||
onClose={() => setBillAction(null)}
|
||||
centered
|
||||
radius={18}
|
||||
size={440}
|
||||
title={<Text fw={800}>Pay at CBE</Text>}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c={MUTED}>
|
||||
{billAction?.instructions ??
|
||||
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||
</Text>
|
||||
<Group
|
||||
justify="space-between"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<Text ff="monospace" fz={24} fw={800} c={INK} style={{ letterSpacing: 3 }}>
|
||||
{billAction?.billReference}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
if (billAction?.billReference) {
|
||||
navigator.clipboard?.writeText(billAction.billReference);
|
||||
toast.success("Bill number copied");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz="sm" c={MUTED}>
|
||||
Amount due:{" "}
|
||||
<Text span fw={700} c={INK}>
|
||||
{formatCurrency(amountDue, invoice.currency)}
|
||||
</Text>
|
||||
</Text>
|
||||
{billAction?.expiresAt && (
|
||||
<Text fz="sm" c={MUTED}>
|
||||
Pay before:{" "}
|
||||
<Text span fw={700} c={INK}>
|
||||
{fmtDate(billAction.expiresAt)}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Text fz="xs" c={MUTED}>
|
||||
The invoice updates automatically once CBE confirms your payment.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,7 @@ import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||
@@ -41,6 +37,7 @@ import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||
|
||||
export function ReadonlyBookingView({
|
||||
booking,
|
||||
@@ -53,7 +50,6 @@ export function ReadonlyBookingView({
|
||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||
useScrollToHash();
|
||||
const status = booking.status as string;
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
const { viewer } = useFileViewer();
|
||||
|
||||
// Re-book opens the New Shipment Booking form for the same contract, not the
|
||||
@@ -63,44 +59,11 @@ export function ReadonlyBookingView({
|
||||
: "/contracts/new";
|
||||
const onRebook = () => navigate(rebookTo);
|
||||
|
||||
// Billing is invoice-centric — resolve the booking's currently payable
|
||||
// invoice (same query/key BookingPaymentPanel uses, so this shares its
|
||||
// cache) and pay it through the ownership-checked portal route.
|
||||
const { data: bookingInvoices = [] } = useQuery({
|
||||
queryKey: ["booking-invoices", booking.id],
|
||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||
});
|
||||
const payableInvoiceId = bookingInvoices.find((inv) =>
|
||||
isPayable(inv.status),
|
||||
)?.id;
|
||||
|
||||
// POST /billing/my-invoices/:id/pay creates the intent and returns the
|
||||
// provider's redirect URL (clientAction.url). Send the browser straight
|
||||
// there; fall back to the public /payments/checkout page if no redirect
|
||||
// URL came back.
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payableInvoiceId) {
|
||||
throw new Error(
|
||||
"No payable invoice found for this booking yet. Please refresh or contact support.",
|
||||
);
|
||||
}
|
||||
return api.invoices.pay.call({
|
||||
id: payableInvoiceId,
|
||||
payload: { method, platform: "web" },
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({
|
||||
invoiceId: payableInvoiceId!,
|
||||
method,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
// Billing is invoice-centric — the shared hook resolves the booking's
|
||||
// currently payable invoice (same query/key BookingPaymentPanel uses, so it
|
||||
// shares that cache), charges it through the ownership-checked portal route,
|
||||
// and handles redirect vs CAC Bank OTP.
|
||||
const pay = useBookingPayment(booking.id);
|
||||
|
||||
const pricing = booking.pricingBreakdown;
|
||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||
@@ -171,7 +134,7 @@ export function ReadonlyBookingView({
|
||||
green
|
||||
icon={<CreditCard size={16} />}
|
||||
label="Pay now"
|
||||
onClick={() => setPayModalOpen(true)}
|
||||
onClick={pay.open}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@@ -276,8 +239,8 @@ export function ReadonlyBookingView({
|
||||
<BookingPaymentPanel
|
||||
booking={booking}
|
||||
pricing={pricing}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
onPay={pay.open}
|
||||
paying={pay.processing}
|
||||
showCountdown={showCountdown}
|
||||
/>
|
||||
<ScheduleCard
|
||||
@@ -327,24 +290,14 @@ export function ReadonlyBookingView({
|
||||
</Tabs>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payModalOpen}
|
||||
onClose={() => {
|
||||
if (!payMutation.isPending) {
|
||||
setPayModalOpen(false);
|
||||
payMutation.reset();
|
||||
}
|
||||
}}
|
||||
opened={pay.modalOpen}
|
||||
onClose={pay.close}
|
||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
{viewer}
|
||||
</PageShell>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucid
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
|
||||
|
||||
interface BulkTruckUploadModalProps {
|
||||
@@ -32,7 +33,7 @@ export function BulkTruckUploadModal({
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, {
|
||||
const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), {
|
||||
trucks: parsed,
|
||||
});
|
||||
return data;
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Check, Landmark, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
|
||||
import type { PaymentMethod } from "@/services/payments.service";
|
||||
|
||||
interface ProviderOption {
|
||||
method: PaymentMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
logo: string;
|
||||
/** Logo asset; falls back to a bank glyph when the provider has none. */
|
||||
logo?: string;
|
||||
/** Currencies this provider settles in. */
|
||||
currencies: string[];
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// Only Telebirr and Waafi are enabled for now.
|
||||
// Only Telebirr, Waafi and CBE bill payment are enabled for now.
|
||||
const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "TELEBIRR",
|
||||
@@ -32,8 +44,26 @@ const PROVIDERS: ProviderOption[] = [
|
||||
currencies: ["USD"],
|
||||
accent: "#2E5B96",
|
||||
},
|
||||
{
|
||||
method: "CAC_BANK",
|
||||
label: "CAC Bank",
|
||||
description: "Djibouti bank debit · confirmed by SMS OTP",
|
||||
currencies: ["USD"],
|
||||
accent: "#8A5A17",
|
||||
},
|
||||
{
|
||||
method: "CBE_BILL",
|
||||
label: "CBE bill payment",
|
||||
description: "Pay at any CBE branch, app or USSD · ETB",
|
||||
logo: "/assets/edr-logo.png",
|
||||
currencies: ["ETB"],
|
||||
accent: "#5B2D8C",
|
||||
},
|
||||
];
|
||||
|
||||
/** Providers that debit against an SMS OTP instead of redirecting to a page. */
|
||||
const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK";
|
||||
|
||||
/**
|
||||
* Pick the provider that settles in the booking's currency. USD → Waafi,
|
||||
* ETB → Telebirr. Falls back to the first provider when unknown.
|
||||
@@ -91,13 +121,27 @@ function ProviderRow({
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={option.logo}
|
||||
alt={`${option.label} logo`}
|
||||
w={52}
|
||||
h={52}
|
||||
fit="cover"
|
||||
/>
|
||||
{option.logo ? (
|
||||
<Image
|
||||
src={option.logo}
|
||||
alt={`${option.label} logo`}
|
||||
w={52}
|
||||
h={52}
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Landmark size={24} color={option.accent} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
||||
@@ -135,6 +179,7 @@ export function PaymentMethodModal({
|
||||
onConfirm,
|
||||
processing,
|
||||
error,
|
||||
otp,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -142,12 +187,19 @@ export function PaymentMethodModal({
|
||||
amountLabel?: string;
|
||||
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
||||
currency?: string | null;
|
||||
onConfirm: (method: PaymentMethod) => void;
|
||||
onConfirm: (method: PaymentMethod, payerAccount?: string) => void;
|
||||
processing?: boolean;
|
||||
error?: string | null;
|
||||
/** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */
|
||||
otp?: InvoicePaymentFlow["otp"];
|
||||
}) {
|
||||
const providers = useMemo(() => providersForCurrency(currency), [currency]);
|
||||
const providers = useMemo(
|
||||
() => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)),
|
||||
[currency, otp],
|
||||
);
|
||||
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||
useEffect(() => {
|
||||
@@ -156,6 +208,89 @@ export function PaymentMethodModal({
|
||||
}
|
||||
}, [providers, method]);
|
||||
|
||||
// A fresh OTP round always starts empty.
|
||||
useEffect(() => {
|
||||
if (otp?.open) setCode("");
|
||||
}, [otp?.open]);
|
||||
|
||||
// CAC Bank debits the account behind this number and SMSes the OTP to it.
|
||||
const needsMobile = isOtpMethod(method);
|
||||
const canSubmit = !needsMobile || mobile.trim().length > 0;
|
||||
|
||||
if (otp?.open) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={otp.cancel}
|
||||
centered
|
||||
radius={18}
|
||||
size={420}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
// A stray click must not drop the payer out of a live OTP window —
|
||||
// Cancel is the only way back.
|
||||
closeOnClickOutside={false}
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||
>
|
||||
<Box px={24} py={24}>
|
||||
<Text fw={800} fz="18px" c="#10202F">
|
||||
Enter OTP
|
||||
</Text>
|
||||
<Text mt={4} fz="13px" c="#7A8794">
|
||||
{otp.message}
|
||||
</Text>
|
||||
|
||||
<Box mt={18}>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
oneTimeCode
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={(value) => otp.submit(value)}
|
||||
aria-label="One-time password"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{otp.error && (
|
||||
<Text mt={10} fz="12.5px" c="#C0392B" fw={600}>
|
||||
{otp.error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap={10} wrap="nowrap" mt={20}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={12}
|
||||
onClick={otp.cancel}
|
||||
disabled={otp.submitting}
|
||||
styles={{
|
||||
root: { height: 46, flex: "0 0 38%" },
|
||||
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
radius={12}
|
||||
color="edr-green"
|
||||
loading={otp.submitting}
|
||||
disabled={otp.submitting || code.trim().length === 0}
|
||||
onClick={() => otp.submit(code.trim())}
|
||||
styles={{
|
||||
root: { height: 46, flex: 1 },
|
||||
label: { fontSize: 14, fontWeight: 800 },
|
||||
}}
|
||||
>
|
||||
Confirm payment
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
@@ -215,6 +350,22 @@ export function PaymentMethodModal({
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{needsMobile && (
|
||||
<TextInput
|
||||
mt={12}
|
||||
label="Mobile number"
|
||||
description="CAC Bank sends a one-time password to this number to authorise the debit."
|
||||
placeholder="77xxxxxx"
|
||||
value={mobile}
|
||||
onChange={(e) => setMobile(e.currentTarget.value)}
|
||||
disabled={processing}
|
||||
styles={{
|
||||
label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" },
|
||||
description: { fontSize: 11.5 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -228,7 +379,9 @@ export function PaymentMethodModal({
|
||||
<Group gap={6} align="center" justify="center" mb={12}>
|
||||
<ShieldCheck size={14} color="#0A8A5F" />
|
||||
<Text fz="11.5px" c="#7A8794">
|
||||
Secured · you'll be redirected to your provider to pay
|
||||
{needsMobile
|
||||
? "Secured · you'll confirm with the OTP sent to your phone"
|
||||
: "Secured · you'll be redirected to your provider to pay"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -248,15 +401,21 @@ export function PaymentMethodModal({
|
||||
<Button
|
||||
radius={12}
|
||||
color="edr-green"
|
||||
disabled={processing}
|
||||
disabled={processing || !canSubmit}
|
||||
loading={processing}
|
||||
onClick={() => onConfirm(method)}
|
||||
onClick={() =>
|
||||
onConfirm(method, needsMobile ? mobile.trim() : undefined)
|
||||
}
|
||||
styles={{
|
||||
root: { height: 48, flex: 1 },
|
||||
label: { fontSize: 14, fontWeight: 800 },
|
||||
}}
|
||||
>
|
||||
{processing ? "Redirecting…" : "Continue to payment"}
|
||||
{processing
|
||||
? needsMobile
|
||||
? "Sending OTP…"
|
||||
: "Redirecting…"
|
||||
: "Continue to payment"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, Download, FileText, Receipt } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import {
|
||||
warehouseInvoicesService,
|
||||
type PortalWarehouseInvoice,
|
||||
@@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
|
||||
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payInvoice) throw new Error("No invoice selected for payment.");
|
||||
return warehouseInvoicesService.payOnline(payInvoice.id, {
|
||||
method,
|
||||
platform: "web",
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
if (!payInvoice) return;
|
||||
// Redirect to the provider (or the fallback checkout page) — same as the
|
||||
// booking "Pay now" flow, so behaviour is identical everywhere.
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
|
||||
const payError = payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null;
|
||||
// Warehouse fees are charged through the warehouse route, but they are the
|
||||
// same central invoices — so redirect vs CAC Bank OTP is the shared flow.
|
||||
const pay = useInvoicePayment((invoiceId, method, payerAccount) =>
|
||||
warehouseInvoicesService.payOnline(invoiceId, {
|
||||
method,
|
||||
platform: "web",
|
||||
payerAccount,
|
||||
}),
|
||||
);
|
||||
|
||||
const closePayModal = () => {
|
||||
if (!payMutation.isPending) {
|
||||
if (!pay.processing) {
|
||||
setPayInvoice(null);
|
||||
payMutation.reset();
|
||||
pay.reset();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
||||
}
|
||||
currency={payInvoice?.currency}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
processing={payMutation.isPending}
|
||||
error={payError}
|
||||
onConfirm={(method, payerAccount) =>
|
||||
payInvoice && pay.pay(payInvoice.id, method, payerAccount)
|
||||
}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -55,6 +55,7 @@ export function PayNowButton({
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
</ModalSafeWrapper>
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
paymentsService,
|
||||
type PaymentMethod,
|
||||
} from "@/services/payments.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { type PaymentMethod } from "@/services/payments.service";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
|
||||
/**
|
||||
* Shared payment flow for a single booking: opens the method modal, fires
|
||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
||||
* invoice, and redirects the browser to the provider (or the fallback
|
||||
* checkout page). Reused by the booking detail page, the booking list, and
|
||||
* the home page so "Pay now" behaves identically everywhere.
|
||||
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
||||
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
||||
* the booking detail page, the booking list, and the home page so "Pay now"
|
||||
* behaves identically everywhere.
|
||||
*/
|
||||
export function useBookingPayment(bookingId: string) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [noInvoice, setNoInvoice] = useState(false);
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ["booking-invoices", bookingId],
|
||||
@@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) {
|
||||
});
|
||||
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payableInvoiceId) {
|
||||
throw new Error(
|
||||
"No payable invoice found for this booking yet. Please refresh or contact support.",
|
||||
);
|
||||
}
|
||||
return api.invoices.pay.call({
|
||||
id: payableInvoiceId,
|
||||
payload: { method, platform: "web" },
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({
|
||||
invoiceId: payableInvoiceId!,
|
||||
method,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
const flow = useInvoicePayment();
|
||||
|
||||
const open = () => setModalOpen(true);
|
||||
|
||||
const close = () => {
|
||||
if (!mutation.isPending) {
|
||||
if (!flow.processing) {
|
||||
setModalOpen(false);
|
||||
mutation.reset();
|
||||
setNoInvoice(false);
|
||||
flow.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const error = mutation.isError
|
||||
? mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null;
|
||||
|
||||
return {
|
||||
modalOpen,
|
||||
open,
|
||||
close,
|
||||
processing: mutation.isPending,
|
||||
error,
|
||||
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
||||
processing: flow.processing,
|
||||
error: noInvoice
|
||||
? "No payable invoice found for this booking yet. Please refresh or contact support."
|
||||
: flow.error,
|
||||
otp: flow.otp,
|
||||
confirm: (method: PaymentMethod, payerAccount?: string) => {
|
||||
if (!payableInvoiceId) {
|
||||
setNoInvoice(true);
|
||||
return;
|
||||
}
|
||||
setNoInvoice(false);
|
||||
flow.pay(payableInvoiceId, method, payerAccount);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,20 +39,15 @@ import {
|
||||
type LicenseFile,
|
||||
type LicenseFileStatus,
|
||||
} from "@/services/companies.service";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The representative's name, email, phone and address all come from their
|
||||
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
|
||||
// is the only detail this form owns.
|
||||
const schema = z.object({
|
||||
poaName: z.string().optional(),
|
||||
poaEmail: z.string().optional(),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaLocation: z.string().optional(),
|
||||
poaAddress: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
@@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({
|
||||
const { view, viewer } = useFileViewer();
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
return {
|
||||
poaName: profile.poaName ?? "",
|
||||
poaEmail: profile.poaEmail ?? "",
|
||||
poaPhone: profile.poaPhone ?? "",
|
||||
poaLocation: profile.poaLocation ?? "",
|
||||
poaAddress: profile.poaAddress ?? "",
|
||||
};
|
||||
}, [profile]);
|
||||
const defaultValues = useMemo(
|
||||
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({
|
||||
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
|
||||
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
|
||||
|
||||
// The letter is staged locally, not uploaded on pick. Uploading immediately
|
||||
// would open a change request, which locks the whole settings page (see
|
||||
// SettingsPage's `locked` fieldset) before the text fields could be saved.
|
||||
// Save submits the file and the fields together, into one change request.
|
||||
// The letter is staged locally, not uploaded on pick: the paper is the one
|
||||
// thing here that still goes to a reviewer, so picking it must not open a
|
||||
// change request before the customer has committed to the save. Save submits
|
||||
// the file and the fields together.
|
||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||
const [saveBlocked, setSaveBlocked] = useState(false);
|
||||
@@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({
|
||||
const requirePoa = profile.companyProfiles.some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
// An Ethiopian company does not type its representative's details — they
|
||||
// come from the Fayda verification. A foreign company keeps the typed form:
|
||||
// its representative may hold no Fayda ID.
|
||||
// No company types its representative's details — they come from the Fayda
|
||||
// verification whatever the nationality, since a representative acts for the
|
||||
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||
// has been verified.
|
||||
const identity = profile.identity;
|
||||
const verifiedIdentity = identity?.faydaRequired === true;
|
||||
|
||||
const poaValues = watch([
|
||||
"poaName",
|
||||
"poaEmail",
|
||||
"poaPhone",
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
]);
|
||||
const poaProvided = verifiedIdentity
|
||||
? (identity?.poa.verified ?? false)
|
||||
: poaValues.some((v) => v?.trim());
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const letterRequired = requirePoa || poaProvided;
|
||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||
|
||||
@@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: FormData) => {
|
||||
// Every identity field except the city is written by the verification, so
|
||||
// an Ethiopian company only ever saves the paper and the location here.
|
||||
const fields = verifiedIdentity
|
||||
? { poaLocation: data.poaLocation || undefined }
|
||||
: {
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
};
|
||||
// only the paper and the location are ever saved here.
|
||||
const fields = { poaLocation: data.poaLocation || undefined };
|
||||
// A fresh upload already stages the removal of every paper on file, so
|
||||
// the explicit removals only need applying when no replacement was
|
||||
// picked. Saving the details after it means the API sees the new paper.
|
||||
@@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={identity.faydaRequired}
|
||||
required={requirePoa}
|
||||
disabled={mutation.isPending}
|
||||
pendingReview={Boolean(
|
||||
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
||||
?.faydaIdentity?.poaFaydaSub,
|
||||
)}
|
||||
onVerified={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
@@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{/* Name, email, phone and address are written by the Fayda
|
||||
verification for an Ethiopian company, so only the city — which
|
||||
the address claim does not reliably decompose into — is typed. */}
|
||||
{!verifiedIdentity && (
|
||||
<>
|
||||
<TextInput
|
||||
label="PoA Full Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Name, email, phone and address are all written by the Fayda
|
||||
verification, so only the city — which the address claim does
|
||||
not reliably decompose into — is typed. */}
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
@@ -342,16 +278,6 @@ export default function TabPowerOfAttorney({
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
{!verifiedIdentity && (
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
@@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{profile.reviewStatus === "pending" && (
|
||||
{/* Keyed on the paper's own staged status, not the company's
|
||||
review state: the details on this tab now apply live, so a
|
||||
pending review is just as likely to be about something else. */}
|
||||
{letters.some((f) => f.status !== "live") && (
|
||||
<Group gap={6} c="edr-amber-text">
|
||||
<Clock size={13} />
|
||||
<Text size="xs" fw={500}>
|
||||
@@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
{mode === "edit" &&
|
||||
verifiedIdentity &&
|
||||
identity?.poa.verified &&
|
||||
!requirePoa && (
|
||||
<Button
|
||||
|
||||
@@ -94,10 +94,12 @@ export interface CompanyInfoResponse {
|
||||
company: CompanyResponse;
|
||||
/**
|
||||
* Open profile-edit review, if any. `pending` locks the settings page + new
|
||||
* contract/booking creation; `rejected` surfaces the note for reapply.
|
||||
* contract/booking creation; `rejected`/`changes_requested` both surface the
|
||||
* note for reapply — `changes_requested` just means the edit appends to the
|
||||
* same request instead of starting a fresh one.
|
||||
*/
|
||||
review?: {
|
||||
status: "pending" | "rejected";
|
||||
status: "pending" | "rejected" | "changes_requested";
|
||||
note: string | null;
|
||||
} | null;
|
||||
}
|
||||
@@ -106,7 +108,7 @@ export interface CompanyInfoResponse {
|
||||
export interface ChangeRequestResponse {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
status: "pending" | "approved" | "rejected" | "changes_requested";
|
||||
snapshot: Record<string, any>;
|
||||
documentFileIds: string[];
|
||||
note: string | null;
|
||||
|
||||
@@ -73,4 +73,10 @@ export const invoicesService = {
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Submit the CAC Bank OTP for an invoice whose intent is awaiting confirmation. */
|
||||
confirmOtp: async (id: string, otp: string): Promise<InitiateResponse> => {
|
||||
const { data } = await client.post(B.CONFIRM_INVOICE_OTP(id), { otp });
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@ export type PaymentMethod =
|
||||
| "WAAFI"
|
||||
| "CARD"
|
||||
| "DMONEY"
|
||||
| "CAC_BANK";
|
||||
| "CAC_BANK"
|
||||
| "CBE_BILL";
|
||||
|
||||
export type PaymentPlatform = "web" | "mobile";
|
||||
|
||||
@@ -26,13 +27,17 @@ export interface InitiatePaymentPayload {
|
||||
}
|
||||
|
||||
export interface ClientAction {
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
url?: string;
|
||||
appId?: string;
|
||||
receiveCode?: string;
|
||||
shortCode?: string;
|
||||
providerOrderId?: string;
|
||||
message?: string;
|
||||
/** SHOW_BILL_REFERENCE (CBE bill payment) */
|
||||
billReference?: string;
|
||||
instructions?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface InitiateResponse {
|
||||
|
||||
@@ -51,10 +51,12 @@ export interface ProfileResponse {
|
||||
profileId: string;
|
||||
/**
|
||||
* Open profile-edit review. `pending` → the settings page is read-only until an
|
||||
* admin decides; `rejected` → the note explains why and the forms prefill the
|
||||
* declined values so the customer can amend & resubmit.
|
||||
* admin decides; `rejected`/`changes_requested` → the note explains why and
|
||||
* the forms prefill the declined values so the customer can amend & resubmit
|
||||
* (`changes_requested` appends that edit to this same request instead of
|
||||
* starting a fresh one).
|
||||
*/
|
||||
reviewStatus?: "pending" | "rejected" | null;
|
||||
reviewStatus?: "pending" | "rejected" | "changes_requested" | null;
|
||||
reviewNote?: string | null;
|
||||
pendingChanges?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
DMONEY
|
||||
CAC_BANK
|
||||
CBE_BILL
|
||||
|
||||
@@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