diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 44be07550..bdb4c5ff5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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" diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md index 11075e9f0..a99f64d0a 100644 --- a/CLAUDE_NEW.md +++ b/CLAUDE_NEW.md @@ -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. diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index 3c05c8a9f..a1d86723c 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -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 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 0531d056b..b3ed1c802 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index dcf09fbd9..0591cd0b1 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -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, +})); diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts index 2ae202ebd..4b46912b7 100644 --- a/apps/edr-freight-api/src/data-source.ts +++ b/apps/edr-freight-api/src/data-source.ts @@ -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; diff --git a/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts b/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts new file mode 100644 index 000000000..449f3cebe --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface { + name = "AddCbeBillPaymentMethod3050000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + // PostgreSQL does not support removing enum values directly. + } +} diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts new file mode 100644 index 000000000..976aca798 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS approved_at timestamptz`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies + DROP COLUMN IF EXISTS approved_at`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts new file mode 100644 index 000000000..01c1c6991 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts @@ -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 { + 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 { + await queryRunner.dropTable('freight.company_revisions', true); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index e7682879b..dbf2bd4fd 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -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) => { + 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"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ca43295d1..850791f02 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 { + 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 { + 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 { 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), + }; + } } diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts index c29160ab7..354dd004e 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 94e917754..981233df0 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -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, + ); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 3a17784f3..7b1f34f36 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 15f12896f..b8f73dfc8 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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) => ` + ${i + 1} + ${esc(w.wagonType)} + ${esc(w.wagonNumber)} + ${num(w.tareWeightTons, 2)} + ${num(w.equatedLength)} + ${num(w.loadCapacityTons)} + ${esc(arrivalStation)} + ${esc(cargoName)} + ${esc(departureStation)} + ${esc(w.containerNumbers)} + ${esc(w.sealNumbers)} + ${money(prices[i])} + `, + ) + .join(''); + + return ` + + + + Carriage Acceptance Sheet + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Carriage Acceptance Sheet

+
Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}
+
+
+ Sheet No. + CAS-${esc(booking.reference)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ +
+
Marshalled at${esc(header.marshalledAt ?? departureStation)}
+
Arrival at${esc(header.arrivalAt ?? arrivalStation)}
+
Date and time${esc(sheetDate.toLocaleString('en-GB'))}
+
Train No.${esc(header.trainNumber)}
+
Customer${esc(booking.company?.name)}
+
Cargo${esc(cargoName)}
+
+ + + + + + + + + + + + + + + + + + + + ${rows} + + + + + + + + + + + +
SNType of WagonWagon No.Tare WeightEquated LengthLoad CapacityArrival StationCargo NameDeparture StationContainer No.Seal No.Price (${esc(currency)})
${ + pendingWagons + ? `Received lines: ${wagons.length} — wagons pending marshalling` + : `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})` + }${num(totals.tare, 2)}${num(totals.length)}${num(totals.capacity)}Gross weight (tare + load): ${num(totals.tare + totals.load)} T${money(totalAmount)}
+ +
+ ${ + 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.` + } +
+ +
+
Signed by — EDR operations / date
+
Signed by — customer or agent / date
+
Signed by — marshalling yard / date
+
+ +`; + } + /** Resolve trade direction from yard countries; reject client mismatch. */ /** * An intercity corridor is valid when both yards are Ethiopian and at least diff --git a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts new file mode 100644 index 000000000..195e0cab0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts @@ -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]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index ee75460c9..7665d82eb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { + 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 { + 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" }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 7f72d431a..511c3aa25 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -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 = {}) { }, changeRequestRepo: { findPendingByCompanyId: jest.fn(async () => null), + findLatestOpenByCompanyId: jest.fn(async () => null), findByCompanyId: jest.fn(async () => []), create: jest.fn(async (row: Record) => ({ id: "cr-1", @@ -114,6 +120,13 @@ function makeService(overrides: Partial = {}) { })), update: jest.fn(async () => ({ id: "cr-1" })), }, + revisionRepo: { + create: jest.fn(async (row: Record) => ({ + 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 = {}) { 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 () => { diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 450222657..f0a443ba8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 9e1c76477..365bd4639 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -81,6 +81,9 @@ function makeService(overrides: Partial = {}) { 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) => ({ id: "cr-1", @@ -88,6 +91,13 @@ function makeService(overrides: Partial = {}) { })), update: jest.fn(async () => ({ id: "cr-1" })), }, + revisionRepo: { + create: jest.fn(async (row: Record) => ({ + 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 = {}) { 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, diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts index f22123f61..5961be90f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 50cc648ad..d9798af5d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { 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, + actorId?: string | null, + extraChanges: CompanyRevisionChange[] = [], + ): Promise { + 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 = {}; + const staged: Record = {}; + 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 { + 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 { + 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 { 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 { + 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 { 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({ diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts index 73271e0af..1dc4b5a84 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts @@ -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 & { 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 | 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 & { companyId: string } }) => + ({ + where, + }: { + where: { companyId: string; status?: Row["status"] | FindOperator }; + }) => 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, diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts index eb44d56cb..8fb81e6f5 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -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 { constructor( @@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository { return this.repository.findOne({ - where: { companyId, status: ChangeRequestStatus.Pending }, + where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) }, order: { createdAt: "DESC" }, }); } diff --git a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts new file mode 100644 index 000000000..83fa539e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts @@ -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 = { + 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, +): 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)[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`; +} diff --git a/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts new file mode 100644 index 000000000..b04bb35de --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts @@ -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 { + constructor( + @InjectRepository(CompanyRevision) + repo: Repository, + ) { + super(repo); + } + + /** Revision history for a company, newest first. */ + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + order: { createdAt: "DESC" }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 2634e0943..34071e813 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts new file mode 100644 index 000000000..c93c7387c --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -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; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts index 6e7468c8a..a9988cd28 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 6072268dc..f0a19dad7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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 | 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); diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 60f9f9a34..d705e7323 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -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; } } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 5ee6739de..2ba39ecad 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -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", } /** diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts new file mode 100644 index 000000000..222a8364f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 5fe3a3f67..f254e0121 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index 6978ad446..fc58a731c 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -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"; diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index bc446b508..e76240704 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -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 { + 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 }; diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 5c4a4f7f7..ce072beca 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -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"] }) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 0fc5a6ba5..b5ff51c48 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -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 { + return this.billingService.billQuery(request.referenceId); + } } diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts index 1bf8c3f82..e69a0e15a 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 81d1a2a0d..26a0cf5ed 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -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 { - return this.call("POST", "/payments/initiate", request); + /** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */ + async initiate( + request: InitiatePaymentRequest, + ): Promise { + 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 { + 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 { + try { + return await this.call( + "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 { - 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(method: "GET" | "POST", path: string, body?: unknown): Promise { - const url = `${this.baseUrl}${path}`; - try { - const response = await firstValueFrom( - this.http.request({ - 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( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + try { + const response = await firstValueFrom( + this.http.request({ + 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"); } + } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 05267746d..961aa32bd 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -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), diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts new file mode 100644 index 000000000..ed0f8da58 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -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 = {}) { + 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[]) { + const store = [...rows]; + return { + findOneBy: jest.fn((where: Record) => + Promise.resolve( + store.find((r) => + Object.entries(where).every(([k, v]) => r[k] === v), + ) ?? null, + ), + ), + update: jest.fn((where: { id: string }, data: Record) => { + 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, + 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, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 6cb359dba..0f628e39c 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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 = { CARD: "card", DMONEY: "dmoney", CAC_BANK: "cac-bank", + CBE_BILL: "cbe-bill", }; /** @@ -214,8 +219,13 @@ export class PaymentService { async initiate(input: InitiateIntentInput): Promise { 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 { + 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` diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 3b86a940c..255da0ea2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 3b9fddf1f..87e756ba6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 691525efa..6bafcc730 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -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]); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 84293d475..0cf56ded5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -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. */ diff --git a/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts b/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts new file mode 100644 index 000000000..836dd38b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts @@ -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 | null; +}) { + const created: Record[] = []; + + const inventoryRepository = { + findAll: jest.fn().mockResolvedValue(opts.existingInventory ? [opts.existingInventory] : []), + create: jest.fn((row: Record) => { + 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; + 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 }) + .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 }) + .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 }) + .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 }) + .handleBookingUnloadedAtYard({ bookingId: 'b4', tradeDirection: 'IMPORT' }); + + expect(created).toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index b5894c21b..be1c68bbd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 7cd47f203..1159c1151 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { + 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 { 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 { + 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; diff --git a/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts new file mode 100644 index 000000000..9d51107fe --- /dev/null +++ b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts @@ -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; +}); diff --git a/apps/edr-freight-api/src/scripts/migrate.ts b/apps/edr-freight-api/src/scripts/migrate.ts new file mode 100644 index 000000000..7f331e1d3 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/migrate.ts @@ -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 { + 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); +}); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index eda320c5b..d523d2e8a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "EDR Last Mile Returns", + href: "/dashboard/edr-last-mile-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", @@ -1060,6 +1074,8 @@ const App = () => { } /> } /> } /> + } /> + } /> } /> } /> = { +export const FIELD_LABELS: Record = { companyName: "Company name", companyEmail: "Company email", companyPhone: "Company phone", @@ -69,7 +68,7 @@ const FIELD_LABELS: Record = { }; /** 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; const attrs = (company.attributes ?? {}) as Record; const map: Record = { @@ -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(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 }) { + {pending.note && ( + } + > + Changes were requested on an earlier round of this same + submission: {pending.note} — check whether + this resubmission actually addresses it before approving. + + )} + {proposedKeys.length > 0 ? ( {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 +