From 00bd1250ee056406e626e2bcddd2f5d98b13c4e1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 06:36:03 +0000 Subject: [PATCH 01/60] feat: WIP element Chat intergration --- .github/workflows/deploy.yml | 8 +- apps/edr-freight-api/.env.example | 19 ++ apps/edr-freight-api/src/app.module.ts | 6 + .../src/common/booking-guards.ts | 2 + .../edr-freight-api/src/config/chat.config.ts | 59 ++++ .../src/modules/chat/chat-bridge.service.ts | 73 +++++ .../modules/chat/chat-provisioning.service.ts | 192 +++++++++++++ .../src/modules/chat/chat-sso.service.ts | 59 ++++ .../src/modules/chat/chat.controller.ts | 35 +++ .../src/modules/chat/chat.module.ts | 16 ++ .../src/modules/chat/matrix.client.ts | 263 ++++++++++++++++++ .../notification-inbox.module.ts | 3 + .../notification-inbox.service.ts | 12 +- .../src/seed/edr-freight.seed.ts | 1 + .../src/seed/freight-permissions.registry.ts | 11 + apps/edr-freight-web/backoffice/src/App.tsx | 9 + .../src/components/layout/route-meta.ts | 7 + .../components/layout/sidebar-sections.tsx | 7 + .../backoffice/src/features/chat/chatApi.ts | 13 + .../src/features/chat/useChatSso.ts | 19 ++ .../backoffice/src/lib/permissions.ts | 4 + .../src/pages/chat/ChatLaunchPage.tsx | 62 +++++ docker-compose.yaml | 28 ++ infrastructure/matrix/element/Dockerfile | 9 + infrastructure/matrix/element/config.json | 17 ++ infrastructure/matrix/element/sso.html | 36 +++ infrastructure/matrix/synapse/Dockerfile | 15 + .../matrix/synapse/docker-entrypoint.sh | 20 ++ .../matrix/synapse/homeserver.yaml.tmpl | 95 +++++++ infrastructure/matrix/synapse/log.config | 25 ++ scripts/deploy/sync-env-from-server.sh | 5 + 31 files changed, 1128 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/config/chat.config.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat-sso.service.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat.controller.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat.module.ts create mode 100644 apps/edr-freight-api/src/modules/chat/matrix.client.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx create mode 100644 infrastructure/matrix/element/Dockerfile create mode 100644 infrastructure/matrix/element/config.json create mode 100644 infrastructure/matrix/element/sso.html create mode 100644 infrastructure/matrix/synapse/Dockerfile create mode 100644 infrastructure/matrix/synapse/docker-entrypoint.sh create mode 100644 infrastructure/matrix/synapse/homeserver.yaml.tmpl create mode 100644 infrastructure/matrix/synapse/log.config diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1c797dc6..b6a7fdb69 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,8 @@ jobs: "passenger-portal" "passenger-backoffice" "payment-api" + "synapse" + "element-web" ) if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then @@ -84,6 +86,10 @@ jobs: echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") + # synapse / element-web have no per-service filter line: their only + # source is infrastructure/matrix/, already caught by GLOBAL_PATTERN + # above (which redeploys every service), so a dedicated line here + # would never fire. SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) @@ -119,7 +125,7 @@ jobs: - name: Resolve project and build env file run: | case "${{ matrix.service }}" in - freight-api|freight-portal|freight-backoffice|gps-tracker) + freight-api|freight-portal|freight-backoffice|gps-tracker|synapse|element-web) echo "PROJECT=edr-freight" >> "$GITHUB_ENV" echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV" ;; diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d2359dd97..930c8f1ca 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -219,3 +219,22 @@ EIMS_AUTO_SUBMIT=false EIMS_AUTO_SUBMIT_CRON=0 */5 * * * * # MoR rejects documents older than 3 days; the sweep will not attempt those. EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3 +# ── Internal chat (Matrix/Element) ────────────────────────────────────────── +# Disabled by default; /chat/sso and the nightly room/membership reconcile are +# no-ops until enabled. See infrastructure/matrix/. +MATRIX_ENABLED=false +# Synapse URL reachable from this container (docker-compose service DNS in +# prod, e.g. http://synapse:8008 — NOT the public https://matrix.edr.et). +MATRIX_BASE_URL=http://localhost:8008 +# Synapse's own public_baseurl — what Element itself is configured to call. +# Only used to seed the sso.html handoff page's localStorage. +MATRIX_PUBLIC_BASE_URL=https://matrix.edr.et +MATRIX_CHAT_WEB_URL=https://chat.edr.et +MATRIX_SERVER_NAME=matrix.edr.et +# Must exactly match infrastructure/matrix/synapse/.env's MATRIX_JWT_SECRET — +# this is the whole trust boundary for the SSO handoff. +MATRIX_JWT_SECRET= +# access_token of a Synapse server-admin account. Bootstrap it once via +# infrastructure/matrix/synapse's MATRIX_REGISTRATION_SHARED_SECRET (see that +# file's comments) — this app never touches the shared secret itself. +MATRIX_ADMIN_TOKEN= diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d38c6ea92..405612ddf 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,6 +23,7 @@ import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import faydaConfig from "./config/fayda.config"; import eimsConfig from "./config/eims.config"; +import chatConfig from "./config/chat.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -116,7 +117,10 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; import { AuditModule } from "./modules/audit/audit.module"; +// dev replaced the local LoggerMiddleware with the shared RequestLogMiddleware +// and deleted ./logger.middleware, so the branch's import is dropped here. import { RequestLogMiddleware } from "@edr/api-common"; +import { ChatModule } from "./modules/chat/chat.module"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @@ -135,6 +139,7 @@ if (!process.env.APPLICATION_NAME) { rabbitmqConfig, faydaConfig, eimsConfig, + chatConfig, ], }), ScheduleModule.forRoot(), @@ -252,6 +257,7 @@ if (!process.env.APPLICATION_NAME) { FleetHistoryModule, AiModule, AuditModule, + ChatModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 36ed7ae74..f9eab4d39 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -49,6 +49,8 @@ export const MixedAudience = (permission: string | string[]) => export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); +export const ChatSync = () => BookingStaff(FREIGHT_PERMS.chat.sync); + /** * The document-review countdown in the backoffice header. Its own permission so * it can be granted to exactly the position types that decide operation diff --git a/apps/edr-freight-api/src/config/chat.config.ts b/apps/edr-freight-api/src/config/chat.config.ts new file mode 100644 index 000000000..ce20b610b --- /dev/null +++ b/apps/edr-freight-api/src/config/chat.config.ts @@ -0,0 +1,59 @@ +import { registerAs } from '@nestjs/config'; + +export interface ChatConfig { + enabled: boolean; + /** Synapse base URL reachable from this container (client + admin APIs). */ + baseUrl: string; + /** Synapse's public_baseurl — what Element itself is configured to call. Only + * used to seed the sso.html handoff; server-to-server calls use {@link baseUrl}. */ + publicBaseUrl: string; + /** Public Element Web origin — the SSO handoff link points here. */ + webUrl: string; + /** Matrix server_name — the `:domain` half of every MXID. */ + serverName: string; + /** HS256 secret. Must exactly match Synapse's jwt_config.secret. */ + jwtSecret: string; + /** Bearer token for a Synapse server admin account (room/user provisioning). */ + adminToken: string; +} + +const REQUIRED_VARS = [ + 'MATRIX_BASE_URL', + 'MATRIX_PUBLIC_BASE_URL', + 'MATRIX_CHAT_WEB_URL', + 'MATRIX_SERVER_NAME', + 'MATRIX_JWT_SECRET', + 'MATRIX_ADMIN_TOKEN', +] as const; + +export default registerAs('chat', (): ChatConfig => { + const enabled = (process.env.MATRIX_ENABLED ?? 'false').toLowerCase() === 'true'; + if (!enabled) { + return { + enabled: false, + baseUrl: '', + publicBaseUrl: '', + webUrl: '', + serverName: '', + jwtSecret: '', + adminToken: '', + }; + } + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `Internal chat is enabled (MATRIX_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`, + ); + } + + return { + enabled: true, + baseUrl: process.env.MATRIX_BASE_URL!.replace(/\/$/, ''), + publicBaseUrl: process.env.MATRIX_PUBLIC_BASE_URL!.replace(/\/$/, ''), + webUrl: process.env.MATRIX_CHAT_WEB_URL!.replace(/\/$/, ''), + serverName: process.env.MATRIX_SERVER_NAME!, + jwtSecret: process.env.MATRIX_JWT_SECRET!, + adminToken: process.env.MATRIX_ADMIN_TOKEN!, + }; +}); diff --git a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts new file mode 100644 index 000000000..75bbe9a29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import { NotificationType, type NotifyInput } from '@edr/types'; + +import chatConfig from '../../config/chat.config'; +import { MatrixClient } from './matrix.client'; + +const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' }; + +/** + * Best-effort per-type routing to an existing dept room. Anything not listed + * (including GENERIC) falls through to #freight-alerts — safer than a wrong + * guess at which department a type belongs to. Extend as real usage shows + * which types actually want a dept room instead of the shared feed. + * + * `name` matters only if this bridge is the very first thing to touch that + * alias (normally the nightly/on-demand reconcile creates dept rooms first, + * with the position's real name) — ensureRoom never renames an existing + * room, so this must match what ChatProvisioningService would have used. + */ +const ROOM_FOR_TYPE: Partial> = { + [NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' }, + [NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' }, +}; + +/** + * Mirrors BACKOFFICE-audience notifications into chat so staff see them + * without having the inbox open. Hooked once into + * NotificationInboxService.notify() — every one of that service's ~20 + * callers gets this for free. + * + * Gated on BACKOFFICE only: notify() also serves PORTAL (customer) + * notifications, which must never land in an internal staff room. + */ +@Injectable() +export class ChatBridgeService { + private readonly logger = new Logger(ChatBridgeService.name); + + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + private readonly matrix: MatrixClient, + ) {} + + async bridge(input: NotifyInput): Promise { + if (!this.config.enabled) return; + + try { + const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM; + const roomId = await this.matrix.ensureRoom(room.alias, room.name); + const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`; + const html = `${escapeHtml(input.title)}
${escapeHtml(input.body)}${ + input.link ? `
${escapeHtml(input.link)}` : '' + }`; + await this.matrix.sendMessage(roomId, body, html); + } catch (err) { + // Same contract as NotificationInboxService.notify(): a chat-bridge + // failure must never break or roll back the notification that + // triggered it. + this.logger.error( + `Chat bridge failed for ${input.type}: ${(err as Error).message}`, + ); + } + } +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts new file mode 100644 index 000000000..7b748e576 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -0,0 +1,192 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { MatrixClient } from './matrix.client'; + +/** edr-org.seeder.ts's EDR_ORG_KEY / EDR_UNIT_KEY — the org is currently flat + * (one org, one unit), so this is the entire scope of what gets provisioned. */ +const ORG_KEY = 'edr_freight'; +const UNIT_KEY = 'edr_freight_app'; + +const SPACE_ALIAS = 'edr-freight'; +const GENERAL_ALIAS = 'general'; + +interface PositionHolder { + positionKey: string; + positionName: string; + userId: string; + userName: string; +} + +export interface ReconcileResult { + rooms: number; + joined: number; + kicked: number; + deactivated: number; +} + +/** + * Keeps Matrix rooms and their membership in sync with IAM's unit/position + * tree. There is no local hook on "employee position changed" — IAM writes + * happen inside the vendored @tria-plc/iamapi-common package — so this is a + * reconcile loop, not an event handler: nightly, plus on-demand via + * POST /chat/sync. + * + * Room identity is a deterministic alias (#dept-), not a stored + * mapping table — resolved via the directory API, created on first miss. + * Room membership is diffed against Matrix's own joined_members, not a local + * snapshot — so a user removed from IAM disappears from chat on the very + * next reconcile, with no extra state for this service to own. + */ +@Injectable() +export class ChatProvisioningService { + private readonly logger = new Logger(ChatProvisioningService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly matrix: MatrixClient, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_3AM, { name: 'chat-provisioning-reconcile' }) + async scheduledReconcile(): Promise { + try { + const result = await this.reconcile(); + this.logger.log( + `Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` + + `${result.kicked} kicked, ${result.deactivated} deactivated`, + ); + } catch (err) { + // Never throws into the scheduler — chat provisioning must not be able + // to take down anything else on the cron registry. + this.logger.error( + `Chat reconcile failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + + private async currentHolders(): Promise { + return this.dataSource.query( + `SELECT p.key AS "positionKey", + COALESCE(p.name->>'en', p.key) AS "positionName", + e.user_id AS "userId", + COALESCE(iu.name->>'en', iu.username, iu.email) AS "userName" + FROM iam.employee_positions ep + JOIN iam.employees e ON e.id = ep.employee_id + JOIN iam.positions p ON p.id = ep.position_id + JOIN iam.units u ON u.id = p.unit_id + JOIN iam.organizations o ON o.id = u.organization_id + JOIN iam.users iu ON iu.id = e.user_id + WHERE ep.is_current = true + AND e.is_current = true + AND o.key = $1 + AND u.key = $2`, + [ORG_KEY, UNIT_KEY], + ); + } + + /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ + private async syncMembership( + roomId: string, + desiredUserIds: Set, + botMxid: string, + ): Promise<{ joined: number; kicked: string[] }> { + const current = await this.matrix.joinedMembers(roomId); + const currentSet = new Set(current.filter((id) => id !== botMxid)); + + let joined = 0; + for (const userId of desiredUserIds) { + if (!currentSet.has(userId)) { + await this.matrix.forceJoin(roomId, userId); + joined += 1; + } + } + + const kicked: string[] = []; + for (const userId of currentSet) { + if (!desiredUserIds.has(userId)) { + await this.matrix.kick(roomId, userId, 'No longer assigned to this room'); + kicked.push(userId); + } + } + + return { joined, kicked }; + } + + async reconcile(): Promise { + const holders = await this.currentHolders(); + const botMxid = await this.matrix.whoami(); + + const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { + isSpace: true, + }); + const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { + parentSpaceId: spaceId, + }); + + const allUserIds = new Set(holders.map((h) => this.matrix.mxid(h.userId))); + + // Accounts are otherwise only created lazily on first JWT login (see + // ChatSsoService) — force-joining someone who has never clicked "Chat" + // yet 404s ("User not found") without this. + const seenUserIds = new Set(); + for (const h of holders) { + const mxid = this.matrix.mxid(h.userId); + if (seenUserIds.has(mxid)) continue; + seenUserIds.add(mxid); + await this.matrix.ensureUser(mxid, h.userName); + } + + let rooms = 2; // space + general + let joined = 0; + let kicked = 0; + // A user kicked from anything while holding zero current positions + // anywhere in the unit (allUserIds spans every position) is a full + // leaver, not just moved between positions — deactivate their account. + const kickedUserIds = new Set(); + + const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid); + joined += generalDiff.joined; + kicked += generalDiff.kicked.length; + generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + + const byPosition = new Map }>(); + for (const h of holders) { + const entry = byPosition.get(h.positionKey) ?? { + name: h.positionName, + userIds: new Set(), + }; + entry.userIds.add(this.matrix.mxid(h.userId)); + byPosition.set(h.positionKey, entry); + } + + for (const [positionKey, { name, userIds }] of byPosition) { + const roomId = await this.matrix.ensureRoom(`dept-${positionKey}`, name, { + parentSpaceId: spaceId, + }); + rooms += 1; + + const diff = await this.syncMembership(roomId, userIds, botMxid); + joined += diff.joined; + kicked += diff.kicked.length; + diff.kicked.forEach((uid) => kickedUserIds.add(uid)); + } + + let deactivated = 0; + for (const userId of kickedUserIds) { + if (allUserIds.has(userId)) continue; // moved position, still current elsewhere + try { + await this.matrix.deactivateUser(userId); + deactivated += 1; + } catch (err) { + this.logger.warn( + `Failed to deactivate departed user ${userId}: ${(err as Error).message}`, + ); + } + } + + return { rooms, joined, kicked, deactivated }; + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts new file mode 100644 index 000000000..8f9357e10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { SignJWT } from 'jose'; + +import chatConfig from '../../config/chat.config'; +import { MatrixClient } from './matrix.client'; + +/** Matrix login_tokens are single-use and expire in 5 minutes (Synapse default). */ +const JWT_TTL_SECONDS = 60; + +function displayName(user: TCurrentUser): string { + return ( + user.name?.en || + Object.values(user.name ?? {}).find((v) => typeof v === 'string' && v) || + user.username || + user.email + ); +} + +/** + * The SSO handoff: turn an already-authenticated freight session into a + * one-click Element sign-in link, with no second password anywhere. + * + * 1. Sign a short-lived JWT asserting this user's id (Synapse's + * org.matrix.login.jwt auto-registers the account on first use). + * 2. Trade that JWT for a real Matrix access token. + * 3. Trade the access token for a one-shot login_token. + * 4. Hand the caller a link to Element's sso.html shim, which seeds + * localStorage and forwards the token into Element's own login flow. + */ +@Injectable() +export class ChatSsoService { + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + private readonly matrix: MatrixClient, + ) {} + + async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> { + const secret = new TextEncoder().encode(this.config.jwtSecret); + const jwt = await new SignJWT({ name: displayName(user) }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(user.id) + .setIssuer('edr-freight-api') + .setAudience('matrix') + .setIssuedAt() + .setExpirationTime(`${JWT_TTL_SECONDS}s`) + .sign(secret); + + const { access_token } = await this.matrix.loginWithJwt(jwt); + const { login_token } = await this.matrix.getLoginToken(access_token); + + const url = new URL(`${this.config.webUrl}/sso.html`); + url.searchParams.set('t', login_token); + url.searchParams.set('hs', this.config.publicBaseUrl); + return { url: url.toString() }; + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat.controller.ts b/apps/edr-freight-api/src/modules/chat/chat.controller.ts new file mode 100644 index 000000000..0ecca04c0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { ChatSync } from '../../common/booking-guards'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { ChatSsoService } from './chat-sso.service'; + +@ApiTags('chat') +@Controller('chat') +@ApiBearerAuth() +export class ChatController { + constructor( + private readonly sso: ChatSsoService, + private readonly provisioning: ChatProvisioningService, + ) {} + + @Get('sso') + @UseGuards(JwtGuard) + @ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' }) + getSso(@CurrentUser() user: TCurrentUser) { + return this.sso.getSsoUrl(user); + } + + @Post('sync') + @ChatSync() + @ApiOperation({ + summary: 'Re-run the chat room/membership reconcile immediately (normally nightly)', + }) + sync() { + return this.provisioning.reconcile(); + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat.module.ts b/apps/edr-freight-api/src/modules/chat/chat.module.ts new file mode 100644 index 000000000..8df827339 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { ChatBridgeService } from './chat-bridge.service'; +import { ChatController } from './chat.controller'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { ChatSsoService } from './chat-sso.service'; +import { MatrixClient } from './matrix.client'; + +@Module({ + controllers: [ChatController], + providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService], + // ChatBridgeService: consumed by NotificationInboxModule to mirror + // BACKOFFICE notifications into chat — see notification-inbox.module.ts. + exports: [ChatBridgeService], +}) +export class ChatModule {} diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts new file mode 100644 index 000000000..01dafb6de --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -0,0 +1,263 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; + +import chatConfig from '../../config/chat.config'; + +/** + * Thin wrapper over the handful of Matrix Client-Server + Synapse Admin API + * calls this app needs. Not a general Matrix SDK — matrix-js-sdk is a + * browser/Element concern; the server side only ever provisions rooms/users + * and posts bot messages, so a fetch wrapper is the whole job. + * + * All admin-scoped calls act as the account behind MATRIX_ADMIN_TOKEN. That + * same account also posts the notification-bridge messages (see + * ChatBridgeService) — one bot/admin account covers both jobs, no separate + * bot user needed. + */ +@Injectable() +export class MatrixClient { + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + ) {} + + /** `@:` — the one place this format is assembled. */ + mxid(localpart: string): string { + return `@${localpart}:${this.config.serverName}`; + } + + get serverName(): string { + return this.config.serverName; + } + + private async request( + method: string, + path: string, + body?: unknown, + token: string = this.config.adminToken, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + + /** No auth — only /login accepts a bare JWT with nothing else on the request. */ + private async publicRequest( + method: string, + path: string, + body: unknown, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + return (await res.json()) as T; + } + + /** 404 → null. Every other non-2xx still throws via {@link request}. */ + private async requestOrNull( + method: string, + path: string, + token?: string, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` }, + }); + if (res.status === 404) return null; + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + return (await res.json()) as T; + } + + /** Sign an already-authenticated freight session into a Matrix session. */ + loginWithJwt( + jwt: string, + ): Promise<{ access_token: string; user_id: string; device_id: string }> { + return this.publicRequest('POST', '/_matrix/client/v3/login', { + type: 'org.matrix.login.jwt', + token: jwt, + initial_device_display_name: 'EDR Backoffice', + }); + } + + /** The account behind MATRIX_ADMIN_TOKEN — used to exclude the bot itself from membership reconciliation. */ + async whoami(): Promise { + const res = await this.request<{ user_id: string }>( + 'GET', + '/_matrix/client/v3/account/whoami', + ); + return res.user_id; + } + + /** Currently-joined user ids for a room (not full member-event state). */ + async joinedMembers(roomId: string): Promise { + const res = await this.request<{ joined: Record }>( + 'GET', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`, + ); + return Object.keys(res.joined); + } + + /** Exchange a fresh access token for a one-shot login_token (5 min TTL). */ + getLoginToken(accessToken: string): Promise<{ login_token: string }> { + return this.request( + 'POST', + '/_matrix/client/v1/login/get_token', + {}, + accessToken, + ); + } + + /** null when the alias doesn't resolve to a room yet. */ + resolveAlias(alias: string): Promise<{ room_id: string } | null> { + return this.requestOrNull( + 'GET', + `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + ); + } + + createRoom(input: { + alias: string; + name: string; + topic?: string; + isSpace?: boolean; + parentSpaceId?: string; + }): Promise<{ room_id: string }> { + return this.request('POST', '/_matrix/client/v3/createRoom', { + room_alias_name: input.alias, + name: input.name, + topic: input.topic, + preset: 'private_chat', + creation_content: input.isSpace ? { type: 'm.space' } : undefined, + initial_state: input.parentSpaceId + ? [ + { + type: 'm.space.parent', + state_key: input.parentSpaceId, + content: { via: [this.config.serverName], canonical: true }, + }, + ] + : undefined, + }); + } + + addToSpace(spaceId: string, childRoomId: string): Promise { + return this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(childRoomId)}`, + { via: [this.config.serverName] }, + ); + } + + /** + * Get-or-create by alias — the room identity scheme this whole module + * relies on instead of a local id-mapping table. Idempotent: safe to call + * on every reconcile run and every bridged notification alike. + */ + async ensureRoom( + alias: string, + name: string, + opts: { isSpace?: boolean; parentSpaceId?: string } = {}, + ): Promise { + const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`); + if (existing) return existing.room_id; + + const { room_id } = await this.createRoom({ + alias, + name, + isSpace: opts.isSpace, + parentSpaceId: opts.parentSpaceId, + }); + if (opts.parentSpaceId) { + await this.addToSpace(opts.parentSpaceId, room_id); + } + return room_id; + } + + /** + * Create the account if absent (no password — this deployment is JWT-SSO + * only), or no-op if it already exists. Needed before force-joining a + * position holder who has never clicked "Chat": accounts are otherwise + * only created lazily on first JWT login, and the admin join API 404s + * ("User not found") on an account that doesn't exist yet. + */ + async ensureUser(userId: string, displayName?: string): Promise { + const existing = await this.requestOrNull<{ name: string }>( + 'GET', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + ); + if (existing) return; + await this.request( + 'PUT', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + displayName ? { displayname: displayName } : {}, + ); + } + + /** Server-admin force-join — no invite to accept, works even mid-outage for the invitee. */ + forceJoin(roomIdOrAlias: string, userId: string): Promise { + return this.request( + 'POST', + `/_synapse/admin/v1/join/${encodeURIComponent(roomIdOrAlias)}`, + { user_id: userId }, + ); + } + + kick(roomId: string, userId: string, reason: string): Promise { + return this.request( + 'POST', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, + { user_id: userId, reason }, + ); + } + + /** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */ + deactivateUser(userId: string): Promise { + return this.request( + 'POST', + `/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, + { erase: false }, + ); + } + + sendMessage(roomId: string, body: string, formattedBody?: string): Promise { + const txnId = `edr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + formattedBody + ? { + msgtype: 'm.text', + body, + format: 'org.matrix.custom.html', + formatted_body: formattedBody, + } + : { msgtype: 'm.text', body }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts index e9f3af958..aa363ad3d 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -4,6 +4,7 @@ import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entit import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { BackofficeModule } from "../backoffice/backoffice.module"; +import { ChatModule } from "../chat/chat.module"; import { CompaniesModule } from "../companies/companies.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { Notification } from "./entities/notification.entity"; @@ -24,6 +25,8 @@ import { WsAuthService } from "./ws-auth.service"; BackofficeModule, // EmailClientService + SmsClientService (HIGH-priority fan-out) NotificationsModule, + // ChatBridgeService (mirrors BACKOFFICE notifications into chat) + ChatModule, ], controllers: [NotificationInboxController], providers: [ diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts index 7cfbf81df..119019997 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts @@ -1,4 +1,5 @@ import { + NotificationAudience, NotificationChannels, NotificationChannelsSent, NotificationDto, @@ -11,6 +12,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Repository } from "typeorm"; +import { ChatBridgeService } from "../chat/chat-bridge.service"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; @@ -37,6 +39,7 @@ export class NotificationInboxService { private readonly gateway: NotificationsGateway, private readonly emailClient: EmailClientService, private readonly smsClient: SmsClientService, + private readonly chatBridge: ChatBridgeService, @InjectRepository(User) private readonly users: Repository, ) {} @@ -44,11 +47,18 @@ export class NotificationInboxService { /** * Fan a logical notification out to every resolved recipient: persist one row * each, push it live over WebSocket, and (for HIGH priority) also queue - * email/SMS via the existing clients. + * email/SMS via the existing clients. BACKOFFICE-audience notifications are + * also mirrored into internal chat (ChatBridgeService) — a shared-room + * broadcast, not per-recipient, so it runs once regardless of how many (if + * any) in-app rows get created below. Never PORTAL — that's customer-facing + * and must never reach a staff room. */ async notify(input: NotifyInput): Promise { try { const userIds = await this.recipients.resolve(input.recipients); + if (input.audience === NotificationAudience.BACKOFFICE) { + await this.chatBridge.bridge(input); + } if (userIds.length === 0) { this.logger.debug( `notify(${input.type}) resolved 0 recipients — skipped`, diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 1798a8d5f..e4ab41954 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -242,6 +242,7 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ "edr_freight_app:hierarchy_positions:view", "edr_freight_app:hierarchy_employee_assignment:view", "edr_freight_app:position_types:view", + "edr_freight_app:chat:view", ], }, { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 9e905df6d..4043430e2 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -523,6 +523,12 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. +export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'), + perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'), +]; + // D. Finance — payments + invoices export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1641,6 +1647,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...REPORT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, + ...CHAT_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, ...FLEET_RAIL_PERMISSIONS, @@ -1872,6 +1879,10 @@ export const FREIGHT_PERMS = { /** Reject any pending invoice request. */ invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", }, + chat: { + view: 'edr_freight_app:chat:view', + sync: 'edr_freight_app:chat:sync', + }, payments: { view: "edr_freight_app:payments:view", }, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 9ac730bda..2ea1c1890 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -115,6 +115,7 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import { UserManagementRoutes } from "./user-management/route"; import SetPassword from "./shared/components/SetPassword"; import SupportInboxPage from "./pages/support/SupportInboxPage"; +import ChatLaunchPage from "./pages/chat/ChatLaunchPage"; import { APP_TITLE, buildSidebarSections, @@ -298,6 +299,14 @@ const App = () => { } /> + + + + } + /> = [ subtitle: "Manage your account and signature", }, }, + { + prefix: "/dashboard/chat", + meta: { + title: "Chat", + subtitle: "Internal messaging for EDR staff", + }, + }, { // Invoices, Payments, and USD Payments are tabs on one page now // (FinanceHubPage); the header title itself is set per-tab there. diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 293e82450..301bf6939 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -32,6 +32,7 @@ import { Users, Wallet, LifeBuoy, + MessageSquare, TrainFront, XCircle, } from "lucide-react"; @@ -124,6 +125,12 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.support.agentView, }, + { + label: "Chat", + href: "/dashboard/chat", + icon: , + permission: FREIGHT_PERMS.chat.view, + }, ...demoItems, ], }, diff --git a/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts new file mode 100644 index 000000000..65522aafd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts @@ -0,0 +1,13 @@ +import { api } from "@/auth/http"; + +/** + * Internal chat (Matrix/Element) REST calls. Just the one endpoint — Chat + * itself is a separate app (chat.edr.et); this backoffice only ever asks for + * a fresh sign-in link into it. + */ +export const chatApi = { + getSsoUrl: async (): Promise => { + const { data } = await api.get<{ url: string }>("/chat/sso"); + return data.url; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts b/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts new file mode 100644 index 000000000..d25b5f4a1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; + +import { chatApi } from "./chatApi"; + +export const CHAT_SSO_KEY = ["chat", "sso"] as const; + +/** + * The login_token this resolves to is single-use and expires in 5 minutes + * (Synapse default) — the global `staleTime: 0` (queryClient.ts) already + * means every fresh mount of the launch page refetches rather than reusing + * a possibly-spent link. + */ +export function useChatSso() { + return useQuery({ + queryKey: CHAT_SSO_KEY, + queryFn: chatApi.getSsoUrl, + retry: false, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index fe4d4fff9..b2797fa59 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -33,6 +33,10 @@ export const FREIGHT_PERMS = { staffUsers: { view: "edr_freight_app:staff:users:view", }, + chat: { + view: "edr_freight_app:chat:view", + sync: "edr_freight_app:chat:sync", + }, bookings: { view: "edr_freight_app:bookings:view", create: "edr_freight_app:bookings:create", diff --git a/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx new file mode 100644 index 000000000..6b51b6512 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx @@ -0,0 +1,62 @@ +import { Alert, Button, Card, Center, Loader, Stack, Text } from "@mantine/core"; +import { MessageSquare, TriangleAlert } from "lucide-react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { useChatSso } from "@/features/chat/useChatSso"; + +/** + * Chat itself lives at chat.edr.et (Element), not in this app — this page's + * only job is a fresh one-click sign-in link into it. A real `` (not + * `window.open()` in a click handler) so the browser never treats it as a + * blocked popup, and no iframe: Element's own CSP refuses to be framed. + */ +export default function ChatLaunchPage() { + const { data: url, isLoading, isError, refetch } = useChatSso(); + + return ( + + + +
+ + {isLoading && } + + {isError && ( + + } + color="red" + title="Couldn't get a sign-in link" + variant="light" + > + Something went wrong reaching chat. Try again. + + + + )} + + {url && ( + + + + Opens EDR Chat in a new tab, already signed in as you. + + + + )} + +
+
+
+ ); +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 5d5faa2de..050afc5df 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -116,3 +116,31 @@ services: env_file: - apps/edr-payment-api/.env restart: always + + # Internal employee chat (freight backoffice). No federation, no public + # registration — see infrastructure/matrix/synapse/homeserver.yaml.tmpl. + synapse: + build: + context: infrastructure/matrix/synapse + ports: + - "${SYNAPSE_PORT:-8008}:8008" + env_file: + - infrastructure/matrix/synapse/.env + volumes: + - matrix-data:/data + # Local dev: Postgres runs in the separate docker-compose.db.dev.yml + # project (different docker network) and is only reachable from here via + # the host's published port — see MATRIX_DB_HOST in synapse/.env.example. + extra_hosts: + - "host.docker.internal:host-gateway" + restart: always + + element-web: + build: + context: infrastructure/matrix/element + ports: + - "${ELEMENT_WEB_PORT:-8080}:80" + restart: always + +volumes: + matrix-data: diff --git a/infrastructure/matrix/element/Dockerfile b/infrastructure/matrix/element/Dockerfile new file mode 100644 index 000000000..5ab02cd9b --- /dev/null +++ b/infrastructure/matrix/element/Dockerfile @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1 +# +# EDR internal chat web client. Unmodified upstream Element Web + our public, +# non-secret config (homeserver URL, branding) and the SSO handoff page. +# Pin the tag; never float on `latest`. +FROM ghcr.io/element-hq/element-web:v1.11.108 + +COPY config.json /app/config.json +COPY sso.html /app/sso.html diff --git a/infrastructure/matrix/element/config.json b/infrastructure/matrix/element/config.json new file mode 100644 index 000000000..6f988d3e9 --- /dev/null +++ b/infrastructure/matrix/element/config.json @@ -0,0 +1,17 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://matrix.edr.et", + "server_name": "matrix.edr.et" + } + }, + "brand": "EDR Chat", + "permalink_prefix": "https://chat.edr.et", + "disable_guests": true, + "disable_3pid_login": true, + "disable_custom_urls": true, + "default_theme": "light", + "settingDefaults": { + "UIFeature.registration": false + } +} diff --git a/infrastructure/matrix/element/sso.html b/infrastructure/matrix/element/sso.html new file mode 100644 index 000000000..59a458fa4 --- /dev/null +++ b/infrastructure/matrix/element/sso.html @@ -0,0 +1,36 @@ + + + + + + Signing in to EDR Chat… + + + + + diff --git a/infrastructure/matrix/synapse/Dockerfile b/infrastructure/matrix/synapse/Dockerfile new file mode 100644 index 000000000..ec424e41f --- /dev/null +++ b/infrastructure/matrix/synapse/Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 +# +# EDR internal chat homeserver. Unmodified upstream Synapse + our config +# template — no source build. Pin the tag; never float on `latest`. +FROM ghcr.io/element-hq/synapse:v1.140.0 + +RUN apt-get update && apt-get install -y --no-install-recommends gettext-base \ + && rm -rf /var/lib/apt/lists/* + +COPY homeserver.yaml.tmpl /synapse/homeserver.yaml.tmpl +COPY log.config /synapse/log.config +COPY docker-entrypoint.sh /synapse/docker-entrypoint.sh +RUN chmod +x /synapse/docker-entrypoint.sh + +ENTRYPOINT ["/synapse/docker-entrypoint.sh"] diff --git a/infrastructure/matrix/synapse/docker-entrypoint.sh b/infrastructure/matrix/synapse/docker-entrypoint.sh new file mode 100644 index 000000000..674bf7947 --- /dev/null +++ b/infrastructure/matrix/synapse/docker-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Renders homeserver.yaml from the template using the runtime env (so +# MATRIX_JWT_SECRET / DB password / registration_shared_secret come from the +# service's .env file, never get baked into the image), then hands off to the +# upstream Synapse image's own entrypoint. +set -eu + +mkdir -p /data +envsubst \ + '${MATRIX_SERVER_NAME} ${MATRIX_PUBLIC_BASEURL} ${MATRIX_DB_USER} ${MATRIX_DB_PASSWORD} ${MATRIX_DB_NAME} ${MATRIX_DB_HOST} ${MATRIX_DB_PORT} ${MATRIX_JWT_SECRET} ${MATRIX_REGISTRATION_SHARED_SECRET}' \ + < /synapse/homeserver.yaml.tmpl > /data/homeserver.yaml + +# start.py's `run` mode (the implicit default we hit below) gosu's straight +# into uid 991 with no chown — it only chowns /data in its `generate` / +# `migrate_config` modes, which we skip by providing our own pre-rendered +# config. Without this, 991 can't write its signing key on first boot. +chown -R 991:991 /data + +export SYNAPSE_CONFIG_PATH=/data/homeserver.yaml +exec /start.py "$@" diff --git a/infrastructure/matrix/synapse/homeserver.yaml.tmpl b/infrastructure/matrix/synapse/homeserver.yaml.tmpl new file mode 100644 index 000000000..2f7d59502 --- /dev/null +++ b/infrastructure/matrix/synapse/homeserver.yaml.tmpl @@ -0,0 +1,95 @@ +# EDR internal chat — Synapse homeserver config. +# +# Rendered to /data/homeserver.yaml at container start by docker-entrypoint.sh +# (envsubst over this template) so secrets come from the runtime env file, +# never baked into the image — same convention as freight-api's .env. +# +# server_name is PERMANENT: it is baked into every user id and event and +# cannot change without wiping the server. Do not repoint this at a +# different value after go-live. +server_name: "${MATRIX_SERVER_NAME}" +public_baseurl: "${MATRIX_PUBLIC_BASEURL}" +pid_file: /data/homeserver.pid + +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation] + compress: false + +database: + name: psycopg2 + args: + user: "${MATRIX_DB_USER}" + password: "${MATRIX_DB_PASSWORD}" + dbname: "${MATRIX_DB_NAME}" + host: "${MATRIX_DB_HOST}" + port: ${MATRIX_DB_PORT} + cp_min: 5 + cp_max: 10 + +media_store_path: /data/media_store +max_upload_size: 50M + +log_config: "/synapse/log.config" + +# Internal comms tool: no federation, no open registration, no E2EE-by-default. +# ponytail: E2EE off — turn on per-room (HR/legal) if compliance asks. +federation_domain_whitelist: [] +enable_registration: false +encryption_enabled_by_default_for_room_type: "off" + +# Employees authenticate via freight-api's SSO handoff, never a Matrix +# password prompt. This is the entire auth story for this deployment. +password_config: + enabled: false + +jwt_config: + enabled: true + secret: "${MATRIX_JWT_SECRET}" + algorithm: "HS256" + issuer: "edr-freight-api" + audiences: ["matrix"] + # Matches the `name` claim chat-sso.service.ts puts in the JWT — only read + # on first login (auto-registration), never updates it on later logins. + display_name_claim: "name" + +# Consumes the login_token minted by freight-api's SSO endpoint via +# POST /_matrix/client/v1/login/get_token (issued against an existing, +# already-JWT-authenticated session — not a bare password grant). +login_via_existing_session: + enabled: true + require_ui_auth: false + token_timeout: 5m + +# Bootstrap-only: used once by ops to register the first admin account +# (register_new_matrix_user against /_synapse/admin/v1/register), whose +# access token becomes MATRIX_ADMIN_TOKEN for freight-api's provisioning +# service. Rotate/remove after bootstrap if desired — nothing else depends +# on shared-secret registration once the admin account exists. +registration_shared_secret: "${MATRIX_REGISTRATION_SHARED_SECRET}" + +trusted_key_servers: [] +suppress_key_server_warning: true + +report_stats: false + +# Synapse's default rc_login is sized to defend against internet-facing +# password brute-forcing. That threat doesn't exist on this deployment — +# password login is off (see password_config above), and the only path in +# requires a freight-api-signed JWT — so the default is mostly just +# punishing legitimate rapid logins from the same office/NAT IP or normal +# page-refresh retries. Loosened, not disabled, to keep some ceiling. +rc_login: + address: + per_second: 100 + burst_count: 200 + account: + per_second: 100 + burst_count: 200 + failed_attempts: + per_second: 100 + burst_count: 200 diff --git a/infrastructure/matrix/synapse/log.config b/infrastructure/matrix/synapse/log.config new file mode 100644 index 000000000..0894e974f --- /dev/null +++ b/infrastructure/matrix/synapse/log.config @@ -0,0 +1,25 @@ +# Log straight to stdout — the container runtime (docker compose logs / the +# self-hosted runner's log collection) owns rotation and retention, matching +# how every other app container in this repo logs. +version: 1 + +formatters: + precise: + format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s" + +handlers: + console: + class: logging.StreamHandler + formatter: precise + stream: ext://sys.stdout + +loggers: + synapse.storage.SQL: + # SQL queries are DEBUG-only noise; leave at INFO unless diagnosing. + level: INFO + +root: + level: INFO + handlers: [console] + +disable_existing_loggers: false diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index 69f7eef56..1e9a1ff52 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -31,6 +31,11 @@ declare -A SERVICE_ENV_TARGET=( ["passenger-portal"]="apps/edr-passenger-web/portal/.env" ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" ["payment-api"]="apps/edr-payment-api/.env" + ["synapse"]="infrastructure/matrix/synapse/.env" + # element-web has no runtime secrets (its config.json is baked into the + # image), but the sync step still runs unconditionally per service and + # needs a PORT= line to compute ELEMENT_WEB_PORT for docker compose. + ["element-web"]="infrastructure/matrix/element/.env" ) for service in "$@"; do From fa16087a4a373ad1c5522b7e119fbcb47b376c8a Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 17 Aug 2026 15:33:43 +0300 Subject: [PATCH 02/60] fix: ( excess-baggage ) pay in the selected method's currency and record settlement --- .../excess-baggage-currency.spec.ts | 451 ++++++++++++++++++ .../excess-baggage.controller.ts | 48 +- .../excess-baggage/excess-baggage.dto.ts | 29 +- .../excess-baggage/excess-baggage.module.ts | 9 +- .../excess-baggage/excess-baggage.service.ts | 235 ++++++++- .../payments/internal-payments.controller.ts | 7 + .../modules/payments/payments.service.spec.ts | 196 ++++++++ .../src/modules/payments/payments.service.ts | 142 ++++++ .../test/money-integrity.e2e-spec.ts | 12 +- .../app/excess-baggage/pay/[token]/page.tsx | 421 +++++++++++++++- 10 files changed, 1522 insertions(+), 28 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts new file mode 100644 index 000000000..abc79c798 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts @@ -0,0 +1,451 @@ +import { BadRequestException } from '@nestjs/common'; +import { PaymentMethodType } from '@prisma/client'; +import { ExcessBaggageService } from './excess-baggage.service'; +import { CurrencyService } from '../currency/currency.service'; + +/** + * An excess baggage charge is always booked in ETB, but each payment method settles in its own + * currency and the payment microservice forwards whatever it is given straight to the gateway. + * These cover the ETB→settlement conversion that has to happen here — and that the quote shown to + * the payer is computed from the same code path as the amount actually charged. + */ +describe('ExcessBaggageService — charge currency', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + + // 350.00 ETB owed for 7kg at 50.00 ETB/kg. + const charge = { + id: CHARGE_ID, + totalMinor: 35_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + booking: { bookingRef: 'BAG-001', scheduleId: 'sched-1' }, + }; + + let prisma: Record; + let paymentClient: { + initiate: jest.Mock; + getIntentByReference: jest.Mock; + confirmOtp: jest.Mock; + }; + let service: ExcessBaggageService; + + const build = (rate?: { rate: number }) => { + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + }, + paymentMethod: { findUnique: jest.fn() }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue(rate ?? null), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + status: 'REQUIRES_ACTION', + clientAction: { type: 'REDIRECT', url: 'https://gateway.test/pay' }, + merchantOrderId: 'MO-1', + }), + getIntentByReference: jest.fn(), + confirmOtp: jest.fn(), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, // auditService + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, // notifications + {} as any, // smsClient + {} as any, // emailClient + ); + }; + + const withMethod = (type: string, currency: string) => + prisma.paymentMethod.findUnique.mockResolvedValue({ type, currency }); + + it('charges an Ethiopian wallet in ETB, unconverted', async () => { + build(); + withMethod(PaymentMethodType.TELEBIRR, 'ETB'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.TELEBIRR); + + expect(quote).toMatchObject({ currency: 'ETB', amount: 350 }); + expect(prisma.currencyExchangeRate.findFirst).not.toHaveBeenCalled(); + }); + + it('converts to DJF for Waafi and rounds to whole francs', async () => { + build({ rate: 3.2 }); // 1 ETB = 3.2 DJF + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.WAAFI); + + // 350.00 ETB × 3.2 = 1120 DJF — DJF has no minor unit. + expect(quote).toMatchObject({ currency: 'DJF', amount: 1120 }); + expect(Number.isInteger(quote.amount)).toBe(true); + }); + + it('sends the provider the converted amount and its own currency, not the stored ETB total', async () => { + build({ rate: 3.2 }); + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + await service.initiatePayment(TOKEN, { + method: PaymentMethodType.WAAFI, + platform: 'web', + } as any); + + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + referenceType: 'EXCESS_BAGGAGE', + referenceId: CHARGE_ID, + amountMinor: 1120, + currency: 'DJF', + provider: PaymentMethodType.WAAFI, + }), + ); + }); + + it('quotes and charges the same figure for the same method', async () => { + build({ rate: 0.0175 }); // 1 ETB = 0.0175 USD + withMethod(PaymentMethodType.CARD, 'USD'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CARD); + await service.initiatePayment(TOKEN, { + method: PaymentMethodType.CARD, + platform: 'web', + } as any); + + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(quote.amount).toBe(sent.amountMinor); + expect(quote.currency).toBe(sent.currency); + expect(sent.amountMinor).toBe(6.13); // 350 × 0.0175 = 6.125 → 6.13 USD + }); + + it('forces ETB for CBE_BILL, which settles ETB only', async () => { + build({ rate: 3.2 }); + withMethod(PaymentMethodType.CBE_BILL, 'DJF'); // misconfigured row must not win + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CBE_BILL); + + expect(quote).toMatchObject({ currency: 'ETB', amount: 350 }); + }); + + it('refuses WALLET, which has no excess-baggage path', async () => { + build(); + + await expect( + service.quoteAmount(TOKEN, PaymentMethodType.WALLET), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.WALLET, + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('fails closed when no exchange rate is configured — never charges at parity', async () => { + build(); // no rate rows at all + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.WAAFI, + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); +}); + +/** + * CAC Bank is an OTP debit: the bank SMSes a one-time password to a mobile number it must be given + * at initiate, and the payment only settles once that password is submitted back. + */ +describe('ExcessBaggageService — CAC Bank OTP debit', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + + const charge = { + id: CHARGE_ID, + totalMinor: 25_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + booking: { bookingRef: 'BAG-001', scheduleId: 'sched-1' }, + }; + + let prisma: Record; + let paymentClient: { + initiate: jest.Mock; + getIntentByReference: jest.Mock; + confirmOtp: jest.Mock; + }; + let service: ExcessBaggageService; + + beforeEach(() => { + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + }, + paymentMethod: { + findUnique: jest + .fn() + .mockResolvedValue({ type: 'CAC_BANK', currency: 'DJF' }), + }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue({ rate: 3.25 }), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { + type: 'COLLECT_OTP', + message: 'Enter the OTP sent to 77****56', + }, + merchantOrderId: 'MO-1', + }), + getIntentByReference: jest + .fn() + .mockResolvedValue({ intentId: 'intent-1', status: 'REQUIRES_ACTION' }), + confirmOtp: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'SUCCEEDED', + providerTxnId: 'CAC-TXN-9', + }), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, + {} as any, + {} as any, + ); + }); + + it('rejects initiate without a payer mobile — the bank has nowhere to send the OTP', async () => { + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.CAC_BANK, + platform: 'web', + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('forwards the payer mobile and returns the OTP client action', async () => { + const result = await service.initiatePayment(TOKEN, { + method: PaymentMethodType.CAC_BANK, + platform: 'web', + payerAccount: ' 77123456 ', + } as any); + + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + payerAccount: '77123456', // trimmed + currency: 'DJF', + amountMinor: 813, // 250.00 ETB × 3.25, whole francs + }), + ); + expect(result.clientAction).toMatchObject({ type: 'COLLECT_OTP' }); + }); + + it('submits the OTP against the charge’s active intent and marks it paid', async () => { + const result = await service.confirmOtp(TOKEN, '4530'); + + expect(paymentClient.getIntentByReference).toHaveBeenCalledWith( + 'EXCESS_BAGGAGE', + CHARGE_ID, + ); + expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530'); + expect(prisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ status: 'PAID' }), + }), + ); + expect(result).toMatchObject({ status: 'SUCCEEDED', alreadyPaid: false }); + }); + + it('leaves the charge unpaid when the OTP does not settle', async () => { + paymentClient.confirmOtp.mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + }); + + const result = await service.confirmOtp(TOKEN, '0000'); + + expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: 'REQUIRES_ACTION' }); + }); + + it('confirms an OTP even after the link TTL lapsed — the debit is already in flight', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + expiresAt: new Date(Date.now() - 60_000), + }); + + await expect(service.confirmOtp(TOKEN, '4530')).resolves.toMatchObject({ + status: 'SUCCEEDED', + }); + }); + + it('is idempotent once the charge is already paid', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + }); + + const result = await service.confirmOtp(TOKEN, '4530'); + + expect(result).toMatchObject({ alreadyPaid: true }); + expect(paymentClient.confirmOtp).not.toHaveBeenCalled(); + }); +}); + +/** + * CBE bill payment is inbound-only: no provider session is opened, a bill reference is minted and + * the payer settles it at a branch/app hours later. The expiry handed to the payment service is + * therefore the charge's own deadline, never the 30-minute link TTL — a short one would have the + * reconciliation sweep kill the intent within the hour (CBE plan §6.4). + */ +describe('ExcessBaggageService — CBE bill', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + const THIRTY_MIN = 30 * 60 * 1000; + + let prisma: Record; + let paymentClient: { initiate: jest.Mock }; + let service: ExcessBaggageService; + let charge: any; + + beforeEach(() => { + charge = { + id: CHARGE_ID, + bookingId: 'booking-1', + totalMinor: 25_000, + currency: 'ETB', + status: 'PENDING', + // A freshly created charge: the short browser-session TTL. + expiresAt: new Date(Date.now() + THIRTY_MIN), + booking: { bookingRef: 'BAG-001' }, + }; + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue(charge), + }, + booking: { + findUnique: jest.fn().mockResolvedValue({ + seats: [{ leg: 1, passengerName: 'Abebe Kebede' }], + passenger: { user: { fullName: 'Account Holder' } }, + }), + }, + paymentMethod: { + findUnique: jest + .fn() + .mockResolvedValue({ type: 'CBE_BILL', currency: 'ETB' }), + }, + currencyExchangeRate: { findFirst: jest.fn().mockResolvedValue(null) }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { + type: 'SHOW_BILL_REFERENCE', + billReference: '900123456', + }, + merchantOrderId: 'MO-1', + }), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, + {} as any, + {} as any, + ); + }); + + const initiate = () => + service.initiatePayment(TOKEN, { + method: PaymentMethodType.CBE_BILL, + platform: 'web', + } as any); + + it('extends the charge deadline past the 30-minute link TTL', async () => { + await initiate(); + + expect(prisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ expiresAt: expect.any(Date) }), + }), + ); + const written = + prisma.excessBaggageCharge.update.mock.calls[0][0].data.expiresAt; + // Comfortably beyond the session TTL — a payer has to reach a branch. + expect(written.getTime()).toBeGreaterThan(Date.now() + 2 * THIRTY_MIN); + }); + + it('hands the payment service that deadline as the intent expiry, in ETB', async () => { + await initiate(); + + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(sent.currency).toBe('ETB'); + expect(sent.amountMinor).toBe(250); + expect(new Date(sent.expiresAt).getTime()).toBeGreaterThan( + Date.now() + 2 * THIRTY_MIN, + ); + }); + + it('sends the lead passenger as Full_Name, which CBE requires', async () => { + await initiate(); + + expect(paymentClient.initiate.mock.calls[0][0].payerName).toBe( + 'Abebe Kebede', + ); + }); + + it('never shortens a deadline the payer already has', async () => { + const farFuture = new Date(Date.now() + 90 * 60 * 60 * 1000); + charge.expiresAt = farFuture; + + await initiate(); + + expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(paymentClient.initiate.mock.calls[0][0].expiresAt).toBe( + farFuture.toISOString(), + ); + }); + + it('returns the bill reference to the caller', async () => { + const result = await initiate(); + expect(result.clientAction).toMatchObject({ + type: 'SHOW_BILL_REFERENCE', + billReference: '900123456', + }); + }); + + it('reports a paid charge through getStatus without the payability gate', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + paidAt: new Date(), + }); + + // getByToken would throw "already paid" here; the poll must simply report it. + await expect(service.getStatus(TOKEN)).resolves.toMatchObject({ + status: 'PAID', + paid: true, + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 40a44f36b..b246566e4 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -1,11 +1,12 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards, SetMetadata } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { IsInt, IsOptional, IsPositive, IsString } from 'class-validator'; import { ExcessBaggageService } from './excess-baggage.service'; import { LogExcessBaggageDto, WaiveChargeDto, InitiateExcessPaymentDto, + ConfirmExcessOtpDto, } from './excess-baggage.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { PassengerAdmin } from '../../common/passenger-guards'; @@ -118,6 +119,37 @@ export class ExcessBaggagePublicController { return this.service.getByToken(token); } + @Get('pay/:token/amount') + @ApiOperation({ + summary: 'Quote the charge in a payment method’s settlement currency', + description: + 'Returns what the given method would debit, converted from the charge’s stored ETB total ' + + 'to that method’s settlement currency (WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian ' + + 'wallets in ETB) at the latest exchange rate. The pay page quotes this before the payer ' + + 'commits; initiating a payment recomputes it identically.', + }) + @ApiQuery({ + name: 'method', + required: true, + example: 'WAAFI', + description: 'Payment method type the payer has selected', + }) + quoteAmount(@Param('token') token: string, @Query('method') method: string) { + return this.service.quoteAmount(token, method); + } + + @Get('pay/:token/status') + @ApiOperation({ + summary: 'Poll the charge’s settlement status (public)', + description: + 'Reports the charge’s current status without the payability gate on GET /pay/:token, so a ' + + 'page can watch for settlement. Used while a CBE bill is outstanding and after a redirect ' + + 'payment returns — both settle server-side, out of band from the browser.', + }) + getStatus(@Param('token') token: string) { + return this.service.getStatus(token); + } + @Post('pay/:token/initiate') @ApiOperation({ summary: 'Passenger initiates payment for excess baggage charge' }) initiatePayment( @@ -126,4 +158,18 @@ export class ExcessBaggagePublicController { ) { return this.service.initiatePayment(token, dto); } + + @Post('pay/:token/confirm') + @ApiOperation({ + summary: 'Confirm an OTP-debit excess baggage payment (CAC Bank)', + description: + 'Submits the one-time password the payer received by SMS. A wrong or expired OTP returns ' + + '400 and the payment stays open for retry.', + }) + confirmOtp( + @Param('token') token: string, + @Body() dto: ConfirmExcessOtpDto, + ) { + return this.service.confirmOtp(token, dto.otp); + } } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index df0abd8be..4b06e906e 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -20,7 +20,34 @@ export class WaiveChargeDto { } export class InitiateExcessPaymentDto { - @ApiProperty({ enum: ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'DMONEY', 'CARD'] }) + @ApiProperty({ + enum: [ + 'TELEBIRR', + 'CBE_BIRR', + 'EBIRR', + 'WAAFI', + 'DMONEY', + 'CARD', + 'CAC_BANK', + 'CBE_BILL', + ], + }) @IsString() method: string; @ApiPropertyOptional({ enum: ['web', 'mobile'] }) @IsOptional() platform?: string; + @ApiPropertyOptional({ + description: + 'Payer account / mobile number. Required for the push-debit methods: CAC_BANK (the bank ' + + 'SMSes a one-time password to this number) and EBIRR (the wallet pushes a USSD PIN prompt ' + + 'to it). Normalised server-side by the payment service.', + example: '77123456', + }) + @IsOptional() @IsString() payerAccount?: string; +} + +export class ConfirmExcessOtpDto { + @ApiProperty({ + description: 'One-time password the payer received by SMS (CAC Bank).', + example: '4530', + }) + @IsString() otp: string; } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts index e734d4fb4..e9c648ca8 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts @@ -6,11 +6,18 @@ import { ExcessBaggagePublicController, } from './excess-baggage.controller'; import { PaymentsModule } from '../payments/payments.module'; +import { CurrencyModule } from '../currency/currency.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { AuditModule } from '../../common/audit.module'; @Module({ - imports: [HttpModule, PaymentsModule, NotificationsModule, AuditModule], + imports: [ + HttpModule, + PaymentsModule, + CurrencyModule, + NotificationsModule, + AuditModule, + ], controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController], providers: [ExcessBaggageService], exports: [ExcessBaggageService], diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 32fc06c13..d70c823e5 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; +import { CurrencyService } from '../currency/currency.service'; import { PaymentClientService } from '../payments/payment-client.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SmsClientService } from '../notifications/sms-client.service'; @@ -25,6 +26,41 @@ import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes +/** + * WALLET is an internal balance debit handled entirely inside this app (PaymentsService + * .initiateWalletPayment) — it is not a provider and the payment microservice rejects it as one. + * Excess baggage has no wallet path, so it is refused up front with a message a payer can act on + * rather than a 502 from the gateway layer. + */ +const UNSUPPORTED_METHODS = new Set([PaymentMethodType.WALLET]); + +/** + * Push-debit methods charge an account we must be told up front — CAC Bank SMSes a one-time + * password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could + * collect the number later, so initiate is rejected without it (mirrors PaymentsService). + */ +const METHODS_REQUIRING_PAYER_ACCOUNT = new Set([ + PaymentMethodType.CAC_BANK, + PaymentMethodType.EBIRR, +]); + +/** + * How long an excess baggage charge stays payable once a CBE bill has been issued for it. + * + * The 30-minute link TTL is a browser-session window: it assumes the payer is sitting in front of + * the page. A CBE bill is the opposite — the payer walks to a branch, or opens CBE Birr later, and + * the bill reference may already be written on a slip of paper. Handing the payment service a + * 30-minute `expiresAt` would also make the reconciliation sweep expire the intent and emit + * payment.failed within the hour (CBE_IMPLEMENTATION_PLAN.md §6.4 calls this the single most + * important detail of the integration). + * + * So issuing a bill EXTENDS the charge's own deadline to this window. `charge.expiresAt` stays the + * single source of truth for both the pay link and the bill. + */ +const CBE_BILL_WINDOW_HOURS = Number( + process.env.EXCESS_BAGGAGE_CBE_BILL_HOURS ?? 24, +); + @Injectable() export class ExcessBaggageService { private readonly logger = new Logger(ExcessBaggageService.name); @@ -32,6 +68,7 @@ export class ExcessBaggageService { constructor( private prisma: PrismaService, private auditService: AuditService, + private currencyService: CurrencyService, private paymentClient: PaymentClientService, private notifications: NotificationsService, private smsClient: SmsClientService, @@ -165,21 +202,103 @@ export class ExcessBaggageService { return charge; } + /** + * What the payer is actually charged when paying this charge with `method`. + * + * The charge itself is always booked in ETB (`ExcessBaggageCharge.currency` defaults to ETB and + * nothing overrides it), but the selected method settles in its own currency — WAAFI/DMONEY in + * DJF, CARD in USD, the Ethiopian wallets in ETB — recorded on the PaymentMethod row. The payment + * microservice is currency-agnostic and hands whatever it is given straight to the gateway + * verbatim, so the ETB→settlement conversion has to happen here or the provider is asked to debit + * an ETB number labelled as its own currency. + * + * Both the quote shown to the payer and the amount sent to the provider come through this one + * method, so the price on the button and the price debited cannot drift apart. + */ + private async resolveChargeAmount( + charge: { totalMinor: number; currency: string }, + method: string, + ): Promise<{ amount: number; currency: string }> { + if (UNSUPPORTED_METHODS.has(method)) { + throw new BadRequestException( + `${method} is not available for excess baggage payments`, + ); + } + + const paymentMethod = await this.prisma.paymentMethod.findUnique({ + where: { type: method as PaymentMethodType }, + }); + // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8) — never converted. Every other + // method charges in its configured settlement currency, falling back to the charge's own. + const chargeCurrency = + method === PaymentMethodType.CBE_BILL + ? 'ETB' + : (paymentMethod?.currency ?? charge.currency).toUpperCase(); + + // Applies the target currency's own precision — DJF rounds to whole francs, ETB/USD to cents. + const amount = await this.currencyService.convertMinorToChargeMajor( + charge.totalMinor, + charge.currency, + chargeCurrency, + ); + return { amount, currency: chargeCurrency }; + } + + /** + * Price quote for the pay page: what `method` would debit, in that method's settlement currency. + * The payer sees this before committing, and `initiatePayment` recomputes it the same way. + */ + async quoteAmount(token: string, method: string) { + const charge = await this.getByToken(token); + const { amount, currency } = await this.resolveChargeAmount(charge, method); + return { chargeId: charge.id, method, currency, amount }; + } + async initiatePayment(token: string, dto: InitiateExcessPaymentDto) { const charge = await this.getByToken(token); + if ( + METHODS_REQUIRING_PAYER_ACCOUNT.has(dto.method) && + !dto.payerAccount?.trim() + ) { + throw new BadRequestException( + `payerAccount (mobile number) is required for ${dto.method}`, + ); + } + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`; + const { amount, currency } = await this.resolveChargeAmount( + charge, + dto.method, + ); + + // CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's + // system until someone pays it. It therefore needs a real deadline and a payer name (Full_Name + // is mandatory in CBE's envelope) rather than the redirect flow's session semantics. + let payerName: string | undefined; + let expiresAt: string | undefined; + if (dto.method === PaymentMethodType.CBE_BILL) { + const deadline = await this.extendForCbeBill(charge); + expiresAt = deadline.toISOString(); + payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined; + } + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType, referenceId: charge.id, orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`, - amountMinor: charge.totalMinor / 100, - currency: charge.currency, + // `amountMinor` is the contract's name but its value is MAJOR units — the provider layer + // charges it verbatim at the currency's own precision (see PaymentIntentSnapshot). + amountMinor: amount, + currency, provider: dto.method as unknown as ProviderMethod, platform: dto.platform as any, + payerAccount: dto.payerAccount?.trim() || undefined, + payerName, + expiresAt, returnUrl, failureUrl: returnUrl, }); @@ -196,6 +315,118 @@ export class ExcessBaggageService { }; } + /** + * Pushes the charge's deadline out to the CBE bill window and returns it. Only ever extends — + * a charge that already has longer left (a re-issued bill, an agent's resend) keeps it, so + * re-initiating a bill can never shorten a window the payer was already given. + */ + private async extendForCbeBill(charge: { + id: string; + expiresAt: Date; + }): Promise { + const target = new Date(Date.now() + CBE_BILL_WINDOW_HOURS * 60 * 60 * 1000); + if (charge.expiresAt >= target) return charge.expiresAt; + + await this.prisma.excessBaggageCharge.update({ + where: { id: charge.id }, + data: { expiresAt: target }, + }); + this.logger.log( + `charge ${charge.id}: expiry extended to ${target.toISOString()} for CBE bill`, + ); + return target; + } + + /** + * Full_Name for CBE's confirmation screen — mandatory in its envelope. The passenger the + * baggage belongs to: lead traveller on the booking, falling back to the account holder. + */ + private async resolvePayerName(bookingId: string): Promise { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { seats: true, passenger: { include: { user: true } } }, + }); + if (!booking) return null; + return ( + booking.seats?.find((s: any) => s.leg === 1)?.passengerName ?? + booking.seats?.[0]?.passengerName ?? + booking.passenger?.user?.fullName ?? + null + ); + } + + /** + * Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid, + * expired or waived charge — the whole point is to report those states. A CBE bill can settle + * long after the payer closed the tab, and the redirect methods only converge when the + * settlement event lands, so the page needs something it can watch. + */ + async getStatus(token: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { paymentToken: token }, + select: { + id: true, + status: true, + paidAt: true, + totalMinor: true, + currency: true, + expiresAt: true, + }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + return { + chargeId: charge.id, + status: charge.status, + paid: charge.status === 'PAID' || charge.status === 'CASH_COLLECTED', + paidAt: charge.paidAt, + totalMinor: charge.totalMinor, + currency: charge.currency, + expiresAt: charge.expiresAt, + }; + } + + /** + * Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the + * payerAccount given at initiate; this forwards it to the payment service and marks the charge + * paid when the debit settles. A wrong or expired OTP bubbles up as a 400 and the intent stays + * open, so the payer can simply re-enter it. + * + * Deliberately reads the charge directly rather than through getByToken: the bank is already + * holding a debit against this payer, and refusing to submit their OTP because the 30-minute + * link TTL lapsed while they were reading the SMS would strand a payment that is mid-flight. + */ + async confirmOtp(token: string, otp: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { paymentToken: token }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') { + return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true }; + } + + const snapshot = await this.paymentClient.getIntentByReference( + 'EXCESS_BAGGAGE' as PaymentReferenceType, + charge.id, + ); + if (!snapshot) { + throw new NotFoundException('No active payment to confirm for this charge'); + } + + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markPaid(charge.id, confirmed.providerTxnId); + } + + return { + chargeId: charge.id, + status: confirmed.status, + alreadyPaid: false, + }; + } + async markPaid(chargeId: string, providerTxnId?: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } }); if (!charge) throw new NotFoundException('Charge not found'); diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts index df5949bde..be46135a1 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -8,6 +8,7 @@ import { UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { PaymentReferenceType } from "@edr/types"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { PaymentEventDto, @@ -51,6 +52,12 @@ export class InternalPaymentsController { async billQuery( @Body() request: BillQueryRequestDto, ): Promise { + // Routed on referenceType: the passenger app issues CBE bills for bookings AND for excess + // baggage charges, and they live in different tables. Treating every referenceId as a + // bookingId would report a perfectly payable baggage bill as NOT_FOUND to the teller. + if (request.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) { + return this.paymentsService.billQueryExcessBaggage(request.referenceId); + } return this.paymentsService.billQuery(request.referenceId); } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 9b94053b6..36b062261 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -46,6 +46,10 @@ describe("PaymentsService", () => { paymentMethod: { findUnique: jest.fn(), }, + excessBaggageCharge: { + findUnique: jest.fn(), + update: jest.fn(), + }, currencyExchangeRate: { findFirst: jest.fn(), }, @@ -562,4 +566,196 @@ describe("PaymentsService", () => { ); }); }); + + /** + * Excess baggage settles through the same outbox → RabbitMQ path as bookings. Before this + * existed the consumer dropped every EXCESS_BAGGAGE event as "foreign-reference", so a charge + * the payer had genuinely paid stayed PENDING until its TTL flipped it to EXPIRED. + */ + describe("handlePaymentEvent — excess baggage", () => { + const CHARGE_ID = "charge-1"; + + const succeededEvent = (overrides: Record = {}) => + ({ + eventId: "evt-1", + eventType: "payment.succeeded", + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.EXCESS_BAGGAGE, + referenceId: CHARGE_ID, + amountMinor: 500, + currency: "ETB", + providerTxnId: "TXN-9", + ...overrides, + }) as any; + + it("marks a pending charge PAID", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PENDING", + }); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ status: "PAID" }), + }), + ); + expect(result).toEqual({ processed: true }); + }); + + it("marks an EXPIRED charge PAID — the TTL governs starting a payment, not receiving one", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "EXPIRED", + }); + + await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PAID" }), + }), + ); + }); + + it("does not re-pay an already PAID charge", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PAID", + }); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toEqual({ processed: true, alreadyFinalized: true }); + }); + + it("accepts a foreign-currency settlement without a short-pay comparison", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PENDING", + }); + + // 500.00 ETB charge settled as 1625 DJF — numerically unlike the stored total. + await service.handlePaymentEvent( + succeededEvent({ amountMinor: 1625, currency: "DJF" }), + ); + + expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PAID" }), + }), + ); + }); + + it("acks a failure event without touching the charge", async () => { + const result = await service.handlePaymentEvent( + succeededEvent({ eventType: "payment.failed" }), + ); + + expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toEqual({ processed: true }); + }); + + it("acks an event for a charge that no longer exists", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(result).toEqual({ + processed: false, + reason: "charge-not-found", + }); + }); + }); + + /** + * The live hop CBE makes while a teller is on the line, for a baggage bill. This is the + * double-payment guard: anything other than stillPayable=true makes CBE refuse the debit. + */ + describe("billQueryExcessBaggage", () => { + const payable = { + id: "charge-1", + excessWeightKg: 7, + totalMinor: 25_000, + status: "PENDING", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + booking: { + bookingRef: "BAG-001", + seats: [{ leg: 1, passengerName: "Abebe Kebede" }], + passenger: { user: { fullName: "Account Holder" } }, + }, + }; + + it("reports a pending charge as payable, in ETB, with the passenger name", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(payable); + + const result = await service.billQueryExcessBaggage("charge-1"); + + expect(result).toMatchObject({ + stillPayable: true, + currency: "ETB", + currentAmountMinor: 250, + payerName: "Abebe Kebede", + }); + expect(result.paymentReason).toContain("BAG-001"); + }); + + it("refuses a charge already paid at the counter in cash", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + status: "CASH_COLLECTED", + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ + stillPayable: false, + reason: "ALREADY_PAID", + }); + }); + + it("refuses a waived charge", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + status: "WAIVED", + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "CANCELLED" }); + }); + + it("refuses a charge whose deadline has passed", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" }); + }); + + it("refuses within the settle margin, so a debit cannot land after expiry", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() + 5_000), // inside the 60s margin + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" }); + }); + + it("reports NOT_FOUND for a bill whose charge is gone", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" }); + }); + }); }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5cd483830..1415c1dcc 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -533,6 +533,73 @@ export class PaymentsService { return { ...base, stillPayable: true, reason: null }; } + /** + * Bill-query for an excess baggage charge — the same live "still payable?" hop as bookings, + * against `ExcessBaggageCharge` instead. This is the double-payment guard for baggage bills: + * once the charge is paid, waived or lapsed, CBE is told to refuse the debit. + * + * The charge's own `expiresAt` is the deadline (extended to the CBE bill window when the bill + * was issued), so there is no separate schedule-derived deadline to compute as there is for a + * booking. + */ + async billQueryExcessBaggage( + chargeId: string, + ): Promise { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id: chargeId }, + include: { + booking: { + include: { seats: true, passenger: { include: { user: true } } }, + }, + }, + }); + // A bill reference we issued whose charge has since been deleted — a data problem, not a + // customer-facing cancellation. + if (!charge) return { stillPayable: false, reason: "NOT_FOUND" }; + + const base = { + payerName: + charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ?? + charge.booking?.seats?.[0]?.passengerName ?? + charge.booking?.passenger?.user?.fullName ?? + null, + // The charge is always booked in ETB and CBE settles ETB only, so no conversion applies. + currentAmountMinor: this.currencyService.displayMinorToChargeMajor( + charge.totalMinor, + "ETB", + ), + currency: "ETB", + // Rendered beside the amount on CBE's confirmation screen. The weight and booking ref are + // both on the agent's slip, so the payer can match the two before confirming. + paymentReason: `Excess baggage ${charge.excessWeightKg}kg — booking ${ + charge.booking?.bookingRef ?? "" + }`.trim(), + }; + + // Paid first: a charge settled by any method (including cash at the counter) must be reported + // as already paid, never as merely "not payable". + if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") { + return { ...base, stillPayable: false, reason: "ALREADY_PAID" }; + } + // A supervisor wrote the charge off; from the payer's side the debt is gone. + if (charge.status === "WAIVED") { + return { ...base, stillPayable: false, reason: "CANCELLED" }; + } + // Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline + // that the sweep expires the intent before the capture is registered. + if ( + charge.status === "EXPIRED" || + charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < + Date.now() + ) { + return { ...base, stillPayable: false, reason: "EXPIRED" }; + } + if (charge.status !== "PENDING") { + return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; + } + return { ...base, stillPayable: true, reason: null }; + } + /** * The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's * origin-segment time and that stop's own check-in window, falling back to the route default. @@ -1396,6 +1463,77 @@ export class PaymentsService { return { processed: true }; } + /** + * Settlement for an excess baggage charge paid through the passenger portal link. + * + * Deliberately has NO short-payment amount guard, unlike the booking path: the charge is stored + * in ETB while `event.amountMinor` arrives in the provider's settlement currency (DJF for + * Waafi/D-Money/CAC, USD for card), so comparing the two directly would reject every legitimate + * cross-currency payment. The amount actually charged was computed server-side at initiate. + * + * An EXPIRED charge is still marked PAID. The link TTL only governs whether a NEW payment may be + * started; once a provider has captured the money the charge is paid, and leaving it EXPIRED + * would hide a real settlement from the agent who has to reconcile it. + */ + private async handleExcessBaggageChargeEvent( + event: PaymentEventDto, + ): Promise { + if (event.eventType === "payment.failed") { + this.logger.warn( + `excess baggage charge ${event.referenceId} payment failed`, + ); + return { processed: true }; + } + + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id: event.referenceId }, + }); + if (!charge) { + // Ack — a missing charge will not appear on redelivery; needs investigation. + this.logger.error( + `mark-paid: no excess baggage charge for reference ${event.referenceId}`, + ); + return { processed: false, reason: "charge-not-found" }; + } + if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") { + return { processed: true, alreadyFinalized: true }; + } + // Money arrived against a charge nobody expected to be paid — record it as PAID (that is the + // truth) but say so loudly: a waived charge that settles anyway needs a refund decision. + if (charge.status !== "PENDING") { + this.logger.warn( + `mark-paid: excess baggage charge ${charge.id} settled while ${charge.status} ` + + `(${event.amountMinor} ${event.currency}) — marking PAID; needs review`, + ); + } + + await this.prisma.excessBaggageCharge.update({ + where: { id: charge.id }, + data: { + status: "PAID", + // The provider's own capture time, not when this event happened to be processed — a + // replayed or dead-lettered event must not backdate the money to the wrong minute. + paidAt: event.paidAt ? new Date(event.paidAt) : new Date(), + }, + }); + await this.auditService.log({ + action: "UPDATE", + entityType: "ExcessBaggageCharge", + entityId: charge.id, + oldData: { status: charge.status }, + newData: { + status: "PAID", + providerTxnId: event.providerTxnId, + settledAmount: event.amountMinor, + settledCurrency: event.currency, + }, + }); + this.logger.log( + `excess baggage charge ${charge.id} marked PAID (${event.amountMinor} ${event.currency}, txn ${event.providerTxnId ?? "n/a"})`, + ); + return { processed: true }; + } + async handlePaymentEvent( event: PaymentEventDto, ): Promise { @@ -1410,6 +1548,10 @@ export class PaymentsService { return this.handleSupplementaryChargeEvent(event); } + if (event.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) { + return this.handleExcessBaggageChargeEvent(event); + } + if (event.referenceType !== PaymentReferenceType.BOOKING) { this.logger.warn( `mark-paid: ignoring unknown referenceType ${event.referenceType}`, diff --git a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts index dace45843..91b0a4f2f 100644 --- a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -130,11 +130,12 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { const service = new ExcessBaggageService( prisma as any, - asyncStub(), - asyncStub(), - asyncStub(), - asyncStub(), - asyncStub(), + asyncStub(), // auditService + asyncStub(), // currencyService + asyncStub(), // paymentClient + asyncStub(), // notifications + asyncStub(), // smsClient + asyncStub(), // emailClient ); const charge: any = await service.logCharge({ @@ -174,6 +175,7 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { const service = new ExcessBaggageService( prisma as any, asyncStub(), // auditService + asyncStub(), // currencyService asyncStub(), // paymentClient asyncStub(), // notifications asyncStub(), // smsClient diff --git a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx index 209651756..73976fcda 100644 --- a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx @@ -1,14 +1,17 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { PaymentMethod } from "@/types"; import { AlertCircle, + Check, CheckCircle, + Copy, CreditCard, + KeyRound, Landmark, Loader2, Smartphone, @@ -22,6 +25,28 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; +// WALLET is an internal balance debit with no excess-baggage path — the API refuses it, so it is +// never offered here. +const UNSUPPORTED_METHODS = ["WALLET"]; + +// Push-debit methods charge an account we must know before initiating: CAC Bank SMSes a one-time +// password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could +// collect the number afterwards, so it is asked for up front. +const requiresPayerMobile = (method: string | null) => + method === "CAC_BANK" || method === "EBIRR"; + +// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding, +// so the quote renders exactly the figure the provider will debit. +const formatAmount = (amount: number, currency: string) => + amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2); + +interface AmountQuote { + chargeId: string; + method: string; + currency: string; + amount: number; +} + export default function ExcessBaggagePayPage() { const { token } = useParams<{ token: string }>(); const router = useRouter(); @@ -29,6 +54,26 @@ export default function ExcessBaggagePayPage() { const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); + // Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC — + // the OTP the bank SMSes to it. + const [phoneModalOpen, setPhoneModalOpen] = useState(false); + const [payerMobile, setPayerMobile] = useState(""); + const [phoneError, setPhoneError] = useState(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpMessage, setOtpMessage] = useState(null); + const [otpError, setOtpError] = useState(null); + const [pushMessage, setPushMessage] = useState(null); + + // CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a + // branch/app later, so the page shows the number and watches for settlement. + const [billAction, setBillAction] = useState<{ + billReference: string; + instructions?: string; + expiresAt?: string; + } | null>(null); + const [billCopied, setBillCopied] = useState(false); + const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({ queryKey: ["excessBaggageCharge", token], queryFn: () => apiClient.get(`/excess-baggage/pay/${token}`), @@ -45,24 +90,104 @@ export default function ExcessBaggagePayPage() { enabled: !!charge, }); - const amountDisplay = useMemo(() => { - const amountMinor = Number(charge?.totalMinor ?? charge?.amountMinor ?? 0); - return (amountMinor / 100).toFixed(2); - }, [charge]); + const availableMethods = useMemo( + () => + paymentMethods.filter( + (m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type), + ), + [paymentMethods], + ); - const currency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + // The charge is always booked in ETB; this is what it costs before a method is chosen. + const chargeCurrency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + const chargeAmount = useMemo( + () => Number(charge?.totalMinor ?? charge?.amountMinor ?? 0) / 100, + [charge], + ); + + // Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets + // in ETB), so the price has to be re-quoted server-side whenever the selection changes — the + // stored ETB total is not what a Djiboutian wallet would debit. + const { + data: quote, + isFetching: fetchingQuote, + error: quoteError, + } = useQuery({ + queryKey: ["excessBaggageAmount", token, selectedMethod], + queryFn: () => + apiClient.get( + `/excess-baggage/pay/${token}/amount?method=${selectedMethod}`, + ), + enabled: !!token && !!selectedMethod, + retry: false, + staleTime: 30_000, + }); + + // A quote is only usable once it belongs to the method currently selected — otherwise it is a + // leftover from the previous selection and would price the payment in the wrong currency. + const quoteReady = !fetchingQuote && quote?.method === selectedMethod; + + const displayCurrency = selectedMethod + ? (quote?.currency ?? "") + : chargeCurrency; + const displayAmount = selectedMethod ? quote?.amount : chargeAmount; + const amountLabel = + quoteReady && displayAmount != null + ? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}` + : !selectedMethod && displayAmount != null + ? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}` + : null; + + // Never let Pay fire against a price the payer has not been shown. + const awaitingQuote = !!selectedMethod && !quoteReady; const payMutation = useMutation({ - mutationFn: (method: string) => + mutationFn: (vars: { method: string; payerAccount?: string }) => apiClient.post(`/excess-baggage/pay/${token}/initiate`, { - method, + method: vars.method, platform: "web", + ...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}), }), onSuccess: (data: any) => { - if (data?.clientAction?.type === "REDIRECT") { - window.location.href = data.clientAction.url; + const action = data?.clientAction; + + if (action?.type === "REDIRECT") { + window.location.href = action.url; return; } + + // CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm. + if (action?.type === "COLLECT_OTP") { + setOtpMessage(action.message ?? "Enter the OTP sent to your phone"); + setOtpCode(""); + setOtpError(null); + setOtpModalOpen(true); + setIsProcessing(false); + return; + } + + // CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number. + if (action?.type === "SHOW_BILL_REFERENCE") { + setBillAction({ + billReference: action.billReference, + instructions: action.instructions, + expiresAt: action.expiresAt, + }); + setBillCopied(false); + setIsProcessing(false); + return; + } + + // eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to. + if (action?.type === "AWAIT_PUSH") { + setPushMessage( + action.message ?? + `Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`, + ); + setIsProcessing(false); + return; + } + router.push(`/excess-baggage/pay/${token}/result`); }, onError: (err: any) => { @@ -71,11 +196,92 @@ export default function ExcessBaggagePayPage() { }, }); - const handlePay = () => { + // CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP — + // keep the modal open so the payer can re-enter it (the intent stays open). + const otpMutation = useMutation({ + mutationFn: (otp: string) => + apiClient.post(`/excess-baggage/pay/${token}/confirm`, { otp }), + onSuccess: () => { + setOtpModalOpen(false); + router.push(`/excess-baggage/pay/${token}/result`); + }, + onError: (err: any) => { + setOtpError( + err?.response?.data?.message ?? + err?.message ?? + "Invalid or expired OTP. Please try again.", + ); + }, + }); + + const startPayment = (mobile?: string) => { if (!selectedMethod) return; setIsProcessing(true); setPaymentError(null); - payMutation.mutate(selectedMethod); + payMutation.mutate({ + method: selectedMethod, + payerAccount: requiresPayerMobile(selectedMethod) + ? mobile?.trim() + : undefined, + }); + }; + + const handlePay = () => { + if (!selectedMethod || awaitingQuote) return; + setPaymentError(null); + + if (requiresPayerMobile(selectedMethod)) { + // Prefill with the number the charge was raised against, but leave it editable — the + // handset paying is often not the one the booking was made under. + if (!payerMobile.trim() && charge?.contactPhone) { + setPayerMobile(charge.contactPhone); + } + setPhoneError(null); + setPhoneModalOpen(true); + return; + } + + startPayment(); + }; + + const submitPhone = () => { + if (!payerMobile.trim()) { + setPhoneError("Please enter your mobile number"); + return; + } + setPhoneModalOpen(false); + startPayment(payerMobile); + }; + + // While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens + // server-side — a CBE teller, or the provider's webhook — so the browser has no other signal. + // Success is only ever claimed from this, never from a client-side guess. + const watching = !!billAction || !!pushMessage; + const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({ + queryKey: ["excessBaggageStatus", token], + queryFn: () => + apiClient.get<{ status: string; paid: boolean }>( + `/excess-baggage/pay/${token}/status`, + ), + enabled: !!token && watching, + refetchInterval: 5_000, + }); + + useEffect(() => { + if (watching && liveStatus?.paid) { + router.push(`/excess-baggage/pay/${token}/result`); + } + }, [watching, liveStatus?.paid, router, token]); + + const copyBillReference = async () => { + if (!billAction) return; + try { + await navigator.clipboard.writeText(billAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } catch { + /* clipboard unavailable — the number is still shown on screen */ + } }; if (loadingCharge) { @@ -112,10 +318,25 @@ export default function ExcessBaggagePayPage() {
Amount due - - {currency} {amountDisplay} - + {amountLabel ? ( + {amountLabel} + ) : quoteError ? ( + + ) : ( + + )}
+ {selectedMethod && quoteReady && displayCurrency !== chargeCurrency && ( +

+ Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate +

+ )} + {quoteError && ( +

+ {(quoteError as any)?.response?.data?.message ?? + "This payment method is unavailable right now. Please choose another."} +

+ )}
Weight {charge.excessWeightKg ?? "—"} kg @@ -131,7 +352,7 @@ export default function ExcessBaggagePayPage() {
) : (
- {paymentMethods.filter((m) => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( @@ -166,17 +387,181 @@ export default function ExcessBaggagePayPage() { + + {/* CBE bill — show the number; confirmation only ever comes from the status poll */} + {billAction && ( +
+
+
+ +

Pay at CBE

+
+

+ {billAction.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} +

+
+ + {billAction.billReference} + + +
+
+

+ Amount: ETB {formatAmount(chargeAmount, "ETB")} +

+ {billAction.expiresAt && ( +

+ Pay before:{" "} + + {new Date(billAction.expiresAt).toLocaleString()} + +

+ )} +
+
+ + Waiting for payment confirmation — this page updates automatically once CBE + confirms your payment. +
+ +
+
+ )} + + {/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */} + {pushMessage && ( +
+ +
+

Check your phone

+

{pushMessage}

+
+
+ )} + + {/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */} + {phoneModalOpen && ( +
+
+
+ +

Your mobile number

+
+

+ {selectedMethod === "EBIRR" + ? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you." + : "CAC Bank will send a one-time password to this number to authorize the payment."} +

+ { setPayerMobile(e.target.value); setPhoneError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }} + placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"} + className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {phoneError && ( +

⚠️ {phoneError}

+ )} +
+ + +
+
+
+ )} + + {/* CAC Bank OTP entry */} + {otpModalOpen && ( +
+
+
+ +

Enter OTP

+
+

{otpMessage}

+ { setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }} + onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }} + placeholder="Enter code" + maxLength={10} + className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {otpError && ( +

⚠️ {otpError}

+ )} +
+ + +
+
+
+ )}
); From ab734aecc30e210121dd605eb7c60cd50d44d0a9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 17 Aug 2026 11:33:26 +0000 Subject: [PATCH 03/60] feat(chat): join users to rooms on sign-in --- .../modules/chat/chat-provisioning.service.ts | 59 ++++++++++-- .../src/modules/chat/chat-sso.service.ts | 62 +++++++++--- .../src/modules/chat/matrix.client.spec.ts | 35 +++++++ .../src/modules/chat/matrix.client.ts | 77 +++++++++++++-- .../src/features/chat/useChatSso.ts | 19 ---- .../src/pages/chat/ChatLaunchPage.tsx | 94 +++++++++++-------- docker-compose.yaml | 4 + .../matrix/element/40-element-config.sh | 18 ++++ infrastructure/matrix/element/Dockerfile | 16 +++- .../element/{config.json => config.json.tmpl} | 7 +- infrastructure/matrix/element/manifest.json | 12 +++ infrastructure/matrix/element/sso.html | 47 ++++++---- .../matrix/synapse/homeserver.yaml.tmpl | 18 ++++ scripts/deploy/sync-env-from-env-manager.sh | 6 ++ scripts/deploy/sync-env-from-server.sh | 6 +- 15 files changed, 366 insertions(+), 114 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts delete mode 100644 apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts create mode 100644 infrastructure/matrix/element/40-element-config.sh rename infrastructure/matrix/element/{config.json => config.json.tmpl} (61%) create mode 100644 infrastructure/matrix/element/manifest.json diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts index 7b748e576..b122d8073 100644 --- a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -67,7 +67,8 @@ export class ChatProvisioningService { } } - private async currentHolders(): Promise { + /** Every current holder in the unit, or just one person's rows when `userId` is given. */ + private async currentHolders(userId?: string): Promise { return this.dataSource.query( `SELECT p.key AS "positionKey", COALESCE(p.name->>'en', p.key) AS "positionName", @@ -82,11 +83,53 @@ export class ChatProvisioningService { WHERE ep.is_current = true AND e.is_current = true AND o.key = $1 - AND u.key = $2`, - [ORG_KEY, UNIT_KEY], + AND u.key = $2 + ${userId ? 'AND e.user_id = $3' : ''}`, + userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY], ); } + /** + * Put one person in their rooms right now. + * + * {@link reconcile} is nightly, so without this a new employee's first + * sign-in shows an empty client until 3AM — the SSO handoff creates their + * account but joins them to nothing. Called on every /chat/sso, so it is + * scoped to the one user (a full reconcile per click would be a room-count + * multiple of Matrix calls) and every step is get-or-create. + * + * Someone holding no current position in the unit joins nothing, by the same + * rule the reconcile uses — chat membership follows the org tree. + */ + async joinUserRooms(userId: string, displayName: string): Promise { + const positions = await this.currentHolders(userId); + if (positions.length === 0) return 0; + + const mxid = this.matrix.mxidFor(userId, displayName); + // The JWT login auto-registers too, but that happens after this runs and + // the admin join API 404s on an account that does not exist yet. + await this.matrix.ensureUser(mxid, displayName); + + const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { + isSpace: true, + }); + const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { + parentSpaceId: spaceId, + }); + await this.matrix.ensureJoined(generalRoomId, mxid); + + for (const position of positions) { + const roomId = await this.matrix.ensureRoom( + `dept-${position.positionKey}`, + position.positionName, + { parentSpaceId: spaceId }, + ); + await this.matrix.ensureJoined(roomId, mxid); + } + + return positions.length + 1; + } + /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ private async syncMembership( roomId: string, @@ -99,7 +142,7 @@ export class ChatProvisioningService { let joined = 0; for (const userId of desiredUserIds) { if (!currentSet.has(userId)) { - await this.matrix.forceJoin(roomId, userId); + await this.matrix.ensureJoined(roomId, userId); joined += 1; } } @@ -126,14 +169,16 @@ export class ChatProvisioningService { parentSpaceId: spaceId, }); - const allUserIds = new Set(holders.map((h) => this.matrix.mxid(h.userId))); + const allUserIds = new Set( + holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)), + ); // Accounts are otherwise only created lazily on first JWT login (see // ChatSsoService) — force-joining someone who has never clicked "Chat" // yet 404s ("User not found") without this. const seenUserIds = new Set(); for (const h of holders) { - const mxid = this.matrix.mxid(h.userId); + const mxid = this.matrix.mxidFor(h.userId, h.userName); if (seenUserIds.has(mxid)) continue; seenUserIds.add(mxid); await this.matrix.ensureUser(mxid, h.userName); @@ -158,7 +203,7 @@ export class ChatProvisioningService { name: h.positionName, userIds: new Set(), }; - entry.userIds.add(this.matrix.mxid(h.userId)); + entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName)); byPosition.set(h.positionKey, entry); } diff --git a/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts index 8f9357e10..22edb8e1c 100644 --- a/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts +++ b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts @@ -1,12 +1,13 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { SignJWT } from 'jose'; import chatConfig from '../../config/chat.config'; -import { MatrixClient } from './matrix.client'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { MatrixClient, chatLocalpart } from './matrix.client'; -/** Matrix login_tokens are single-use and expire in 5 minutes (Synapse default). */ +/** Long enough for one login call, short enough to be worthless if it leaks. */ const JWT_TTL_SECONDS = 60; function displayName(user: TCurrentUser): string { @@ -24,36 +25,67 @@ function displayName(user: TCurrentUser): string { * * 1. Sign a short-lived JWT asserting this user's id (Synapse's * org.matrix.login.jwt auto-registers the account on first use). - * 2. Trade that JWT for a real Matrix access token. - * 3. Trade the access token for a one-shot login_token. - * 4. Hand the caller a link to Element's sso.html shim, which seeds - * localStorage and forwards the token into Element's own login flow. + * 2. Trade that JWT for a real Matrix session. + * 3. Hand the caller a link to Element's sso.html shim, which writes that + * session into localStorage and drops the user straight into Element. + * + * Step 3 used to mint a one-shot login_token and let Element redeem it. That + * path is capped at one request per user per minute by a limiter hardcoded in + * Synapse, so a second click inside a minute returned M_LIMIT_EXCEEDED — and a + * spent token surfaces in Element as "Incorrect username and/or password". + * Element accepts a plaintext token out of localStorage (Lifecycle.ts + * getStoredToken/tryDecryptToken), so handing over the session we already hold + * removes both failure modes and one round-trip. */ @Injectable() export class ChatSsoService { + private readonly logger = new Logger(ChatSsoService.name); + constructor( @Inject(chatConfig.KEY) private readonly config: ConfigType, private readonly matrix: MatrixClient, + private readonly provisioning: ChatProvisioningService, ) {} async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> { const secret = new TextEncoder().encode(this.config.jwtSecret); - const jwt = await new SignJWT({ name: displayName(user) }) + const name = displayName(user); + + // Before the link, not after: the reconcile that fills rooms is nightly, so + // a first sign-in would otherwise open an empty client. Best-effort — + // failing to join a room is no reason to refuse someone a sign-in link. + try { + await this.provisioning.joinUserRooms(user.id, name); + } catch (err) { + this.logger.error( + `Room join on sign-in failed for ${user.id}: ${(err as Error).message}`, + ); + } + // Synapse takes the localpart straight from `sub` on auto-registration, so + // this must be byte-identical to what ChatProvisioningService derives for + // the same person — otherwise SSO signs them into one account while the + // reconcile force-joins a different one into the rooms. + const jwt = await new SignJWT({ name }) .setProtectedHeader({ alg: 'HS256' }) - .setSubject(user.id) + .setSubject(chatLocalpart(user.id, name)) .setIssuer('edr-freight-api') .setAudience('matrix') .setIssuedAt() .setExpirationTime(`${JWT_TTL_SECONDS}s`) .sign(secret); - const { access_token } = await this.matrix.loginWithJwt(jwt); - const { login_token } = await this.matrix.getLoginToken(access_token); + const session = await this.matrix.loginWithJwt(jwt); - const url = new URL(`${this.config.webUrl}/sso.html`); - url.searchParams.set('t', login_token); - url.searchParams.set('hs', this.config.publicBaseUrl); - return { url: url.toString() }; + // Session goes in the URL fragment, never the query: a fragment is not sent + // to any server, so the token stays out of Element's access log, and + // sso.html replaces the entry so it does not linger in history either. + const params = new URLSearchParams({ + hs: this.config.publicBaseUrl, + t: session.access_token, + u: session.user_id, + d: session.device_id, + }); + return { url: `${this.config.webUrl}/sso.html#${params.toString()}` }; } } diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts new file mode 100644 index 000000000..ea5faa978 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts @@ -0,0 +1,35 @@ +import { chatLocalpart } from './matrix.client'; + +describe('chatLocalpart', () => { + it('reads from the name, not the id', () => { + expect( + chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'), + ).toBe('nati-wondi.03f5eb'); + }); + + it('separates two people who share a name', () => { + // Both of these are real dev rows — same name, different employees. + const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA'); + const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA'); + expect(a).not.toBe(b); + }); + + it('is stable for the same person', () => { + const id = '7d798218-09de-47a1-98eb-f61ec44e9280'; + expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod')); + }); + + it('still yields a usable localpart for a name that slugs to nothing', () => { + expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe( + 'user.7d7982', + ); + }); + + it('only emits characters Matrix accepts in a localpart', () => { + for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) { + expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch( + /^[a-z0-9._=\-/]+$/, + ); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts index 01dafb6de..1cd09ac33 100644 --- a/apps/edr-freight-api/src/modules/chat/matrix.client.ts +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -14,6 +14,32 @@ import chatConfig from '../../config/chat.config'; * ChatBridgeService) — one bot/admin account covers both jobs, no separate * bot user needed. */ +/** + * Localpart of a staff member's MXID: their name, plus the first 6 hex of + * their freight user id. + * + * The tail is not decoration. Names collide — 19 of the 114 users in the dev + * IAM share a slug with someone else ("MARKOS REGASA" and "Markos REGASA" are + * two different people) — and an MXID is permanent, so a bare slug would hand + * two employees the same Matrix account and each other's rooms. The id is + * already random, so 6 hex of it separates them without a lookup or a mapping + * table, and keeps the derivation pure: ChatSsoService (which mints the JWT + * `sub`) and ChatProvisioningService (which force-joins rooms) must agree on + * this string exactly or they provision two accounts per person. + */ +export function chatLocalpart(userId: string, displayName: string): string { + const slug = displayName + // NFKD splits an accent off its letter; the non-alnum sweep below then + // folds the leftover mark into the same `-` run as the neighbouring space. + .normalize('NFKD') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + // Amharic-only names slug to nothing — the tail still makes it unique. + return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`; +} + @Injectable() export class MatrixClient { constructor( @@ -21,11 +47,28 @@ export class MatrixClient { private readonly config: ConfigType, ) {} + /** + * Alias localparts go in a URL path segment, so a `/` in one is fatal: + * Synapse decodes the path before routing, and `%2F` splits the request into + * a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404 + * as "no such room" and ensureRoom then tries to create the same broken alias + * on every run. Position keys are `edr_freight_app/opn` shaped, so this hits + * every dept room but the handful whose key happens to be a bare word. + */ + private static aliasSafe(alias: string): string { + return alias.replace(/[^A-Za-z0-9._=-]/g, '-'); + } + /** `@:` — the one place this format is assembled. */ mxid(localpart: string): string { return `@${localpart}:${this.config.serverName}`; } + /** The MXID of a freight user — see {@link chatLocalpart}. */ + mxidFor(userId: string, displayName: string): string { + return this.mxid(chatLocalpart(userId, displayName)); + } + get serverName(): string { return this.config.serverName; } @@ -123,15 +166,13 @@ export class MatrixClient { return Object.keys(res.joined); } - /** Exchange a fresh access token for a one-shot login_token (5 min TTL). */ - getLoginToken(accessToken: string): Promise<{ login_token: string }> { - return this.request( - 'POST', - '/_matrix/client/v1/login/get_token', - {}, - accessToken, - ); - } + // No getLoginToken here on purpose. POST /_matrix/client/v1/login/get_token + // is rate limited to 1 request per user per MINUTE, hardcoded in Synapse + // (rest/client/login_token_request.py: "Ratelimit aggressively … could be + // abused by a malicious client to create many sessions") and not settable + // from homeserver.yaml. A second click inside a minute got M_LIMIT_EXCEEDED. + // ChatSsoService hands Element the session from loginWithJwt directly + // instead, which needs no second call. /** null when the alias doesn't resolve to a room yet. */ resolveAlias(alias: string): Promise<{ room_id: string } | null> { @@ -180,10 +221,11 @@ export class MatrixClient { * on every reconcile run and every bridged notification alike. */ async ensureRoom( - alias: string, + rawAlias: string, name: string, opts: { isSpace?: boolean; parentSpaceId?: string } = {}, ): Promise { + const alias = MatrixClient.aliasSafe(rawAlias); const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`); if (existing) return existing.room_id; @@ -228,6 +270,21 @@ export class MatrixClient { ); } + /** + * Force-join, treating "already a member" as success. Synapse answers a + * repeat join with 403 `M_FORBIDDEN: " is already in the room."`, which + * is a failure only if you assumed you knew the membership first. Callers + * that just want someone in a room (sign-in, reconcile racing itself) want + * this; the raw 403 tells them nothing they can act on. + */ + async ensureJoined(roomIdOrAlias: string, userId: string): Promise { + try { + await this.forceJoin(roomIdOrAlias, userId); + } catch (err) { + if (!/already in the room/i.test((err as Error).message)) throw err; + } + } + kick(roomId: string, userId: string, reason: string): Promise { return this.request( 'POST', diff --git a/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts b/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts deleted file mode 100644 index d25b5f4a1..000000000 --- a/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { chatApi } from "./chatApi"; - -export const CHAT_SSO_KEY = ["chat", "sso"] as const; - -/** - * The login_token this resolves to is single-use and expires in 5 minutes - * (Synapse default) — the global `staleTime: 0` (queryClient.ts) already - * means every fresh mount of the launch page refetches rather than reusing - * a possibly-spent link. - */ -export function useChatSso() { - return useQuery({ - queryKey: CHAT_SSO_KEY, - queryFn: chatApi.getSsoUrl, - retry: false, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx index 6b51b6512..78f0e7c36 100644 --- a/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx @@ -1,17 +1,44 @@ -import { Alert, Button, Card, Center, Loader, Stack, Text } from "@mantine/core"; +import { Alert, Button, Card, Center, Stack, Text } from "@mantine/core"; import { MessageSquare, TriangleAlert } from "lucide-react"; +import { useState } from "react"; import { PageContainer, PageHeader } from "@/components/page"; -import { useChatSso } from "@/features/chat/useChatSso"; +import { chatApi } from "@/features/chat/chatApi"; /** * Chat itself lives at chat.edr.et (Element), not in this app — this page's - * only job is a fresh one-click sign-in link into it. A real `
` (not - * `window.open()` in a click handler) so the browser never treats it as a - * blocked popup, and no iframe: Element's own CSP refuses to be framed. + * only job is a one-click sign-in link into it. No iframe: Element's own CSP + * refuses to be framed. + * + * The link is minted per click, never on mount and never cached: Synapse's + * login_token is single-use and expires in 5 minutes, and Element reports a + * spent one as "Incorrect username and/or password". A held-onto url is + * therefore wrong on the second click, on a remount served from cache, and on + * any click more than 5 minutes after the page loaded. */ export default function ChatLaunchPage() { - const { data: url, isLoading, isError, refetch } = useChatSso(); + const [state, setState] = useState<"idle" | "loading" | "error">("idle"); + + const open = async () => { + // Opened before the await so it still counts as the user's click — a + // window.open() after it is treated as a popup and blocked. + // + // No "noopener" in the features: passing it makes window.open return null, + // which would leave this blank tab orphaned and send Element into the + // current tab instead. Clearing .opener on the handle does the same job. + const tab = window.open("", "_blank"); + if (tab) tab.opener = null; + setState("loading"); + try { + const url = await chatApi.getSsoUrl(); + if (tab) tab.location.replace(url); + else window.location.assign(url); // popup blocked — go in this tab + setState("idle"); + } catch { + tab?.close(); + setState("error"); + } + }; return ( @@ -19,41 +46,30 @@ export default function ChatLaunchPage() {
- {isLoading && } - - {isError && ( - - } - color="red" - title="Couldn't get a sign-in link" - variant="light" - > - Something went wrong reaching chat. Try again. - - - + {state === "error" && ( + } + color="red" + title="Couldn't get a sign-in link" + variant="light" + > + Something went wrong reaching chat. Try again. + )} - {url && ( - - - - Opens EDR Chat in a new tab, already signed in as you. - - - - )} + + + + Opens EDR Chat in a new tab, already signed in as you. + + +
diff --git a/docker-compose.yaml b/docker-compose.yaml index 050afc5df..a3f57ebb5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -140,6 +140,10 @@ services: context: infrastructure/matrix/element ports: - "${ELEMENT_WEB_PORT:-8080}:80" + # config.json is rendered at container start, not baked — one image serves + # dev, staging and prod. See infrastructure/matrix/element/.env.example. + env_file: + - infrastructure/matrix/element/.env restart: always volumes: diff --git a/infrastructure/matrix/element/40-element-config.sh b/infrastructure/matrix/element/40-element-config.sh new file mode 100644 index 000000000..9826a5fc7 --- /dev/null +++ b/infrastructure/matrix/element/40-element-config.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Renders /app/config.json from config.json.tmpl at container start. +# +# The homeserver URL and server_name differ per environment, and config.json is +# read by the browser rather than the build, so baking it into the image would +# mean one image per environment. Dropped into /docker-entrypoint.d, which the +# upstream nginx entrypoint runs (in lexical order) before starting nginx — +# no ENTRYPOINT override, so the image's own startup work still happens. +set -eu + +: "${MATRIX_PUBLIC_BASEURL:?MATRIX_PUBLIC_BASEURL is required}" +: "${MATRIX_SERVER_NAME:?MATRIX_SERVER_NAME is required}" +: "${ELEMENT_PUBLIC_URL:?ELEMENT_PUBLIC_URL is required}" + +envsubst '${MATRIX_PUBLIC_BASEURL} ${MATRIX_SERVER_NAME} ${ELEMENT_PUBLIC_URL}' \ + < /app/config.json.tmpl > /app/config.json + +echo "element-config: homeserver ${MATRIX_PUBLIC_BASEURL} (${MATRIX_SERVER_NAME})" diff --git a/infrastructure/matrix/element/Dockerfile b/infrastructure/matrix/element/Dockerfile index 5ab02cd9b..330098af6 100644 --- a/infrastructure/matrix/element/Dockerfile +++ b/infrastructure/matrix/element/Dockerfile @@ -5,5 +5,19 @@ # Pin the tag; never float on `latest`. FROM ghcr.io/element-hq/element-web:v1.11.108 -COPY config.json /app/config.json +COPY config.json.tmpl /app/config.json.tmpl COPY sso.html /app/sso.html +# Replaces the upstream manifest, which names the app "Element" and advertises +# the Play/App Store builds under related_applications. Those apps cannot log +# in here — this deployment has no password login and no SSO provider, only the +# JWT handoff from freight-api — so pointing staff at them is a dead end. +COPY manifest.json /app/manifest.json +COPY 40-element-config.sh /docker-entrypoint.d/40-element-config.sh + +# The image runs as uid 101 (nginx) but ships /app root-owned, so the startup +# hook could not write the rendered config without this. +USER root +RUN chmod +x /docker-entrypoint.d/40-element-config.sh \ + && touch /app/config.json \ + && chown nginx:nginx /app/config.json +USER nginx diff --git a/infrastructure/matrix/element/config.json b/infrastructure/matrix/element/config.json.tmpl similarity index 61% rename from infrastructure/matrix/element/config.json rename to infrastructure/matrix/element/config.json.tmpl index 6f988d3e9..f08900601 100644 --- a/infrastructure/matrix/element/config.json +++ b/infrastructure/matrix/element/config.json.tmpl @@ -1,16 +1,17 @@ { "default_server_config": { "m.homeserver": { - "base_url": "https://matrix.edr.et", - "server_name": "matrix.edr.et" + "base_url": "${MATRIX_PUBLIC_BASEURL}", + "server_name": "${MATRIX_SERVER_NAME}" } }, "brand": "EDR Chat", - "permalink_prefix": "https://chat.edr.et", + "permalink_prefix": "${ELEMENT_PUBLIC_URL}", "disable_guests": true, "disable_3pid_login": true, "disable_custom_urls": true, "default_theme": "light", + "mobile_guide_toast": false, "settingDefaults": { "UIFeature.registration": false } diff --git a/infrastructure/matrix/element/manifest.json b/infrastructure/matrix/element/manifest.json new file mode 100644 index 000000000..75f5f33e6 --- /dev/null +++ b/infrastructure/matrix/element/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "EDR Chat", + "short_name": "EDR Chat", + "display": "standalone", + "theme_color": "#0dbd8b", + "start_url": "index.html", + "icons": [ + { "src": "/vector-icons/150.png", "sizes": "150x150", "type": "image/png" }, + { "src": "/vector-icons/300.png", "sizes": "300x300", "type": "image/png" }, + { "src": "/vector-icons/1024.png", "sizes": "1024x1024", "type": "image/png" } + ] +} diff --git a/infrastructure/matrix/element/sso.html b/infrastructure/matrix/element/sso.html index 59a458fa4..77f378122 100644 --- a/infrastructure/matrix/element/sso.html +++ b/infrastructure/matrix/element/sso.html @@ -1,16 +1,23 @@ @@ -19,17 +26,23 @@ diff --git a/infrastructure/matrix/synapse/homeserver.yaml.tmpl b/infrastructure/matrix/synapse/homeserver.yaml.tmpl index 2f7d59502..efb7372ac 100644 --- a/infrastructure/matrix/synapse/homeserver.yaml.tmpl +++ b/infrastructure/matrix/synapse/homeserver.yaml.tmpl @@ -42,6 +42,24 @@ federation_domain_whitelist: [] enable_registration: false encryption_enabled_by_default_for_room_type: "off" +# Turning rooms' encryption off above is not enough on its own: Element still +# bootstraps cross-signing on a user's first login, and from then on gates +# EVERY later login behind "Verify this device" (MatrixChat: crossSigningIsSetUp +# -> Views.COMPLETE_SECURITY). Nobody on this deployment can clear that gate — +# each SSO click is a brand-new device, so there is never a second verified +# device to accept the request, and resetting the identity needs UIA, which +# password_config.enabled: false makes impossible. +# +# This tells Element encryption is off here, so it skips the bootstrap +# (shouldSkipSetupEncryption) and the gate is never armed. Only helps accounts +# that have no cross-signing keys yet — anyone already bootstrapped keeps +# hitting the gate until their keys are cleared. +extra_well_known_client_content: + io.element.e2ee: + default: false + force_disable: true + secure_backup_required: false + # Employees authenticate via freight-api's SSO handoff, never a Matrix # password prompt. This is the entire auth story for this deployment. password_config: diff --git a/scripts/deploy/sync-env-from-env-manager.sh b/scripts/deploy/sync-env-from-env-manager.sh index 6a405ab28..0835f86d9 100644 --- a/scripts/deploy/sync-env-from-env-manager.sh +++ b/scripts/deploy/sync-env-from-env-manager.sh @@ -29,6 +29,12 @@ declare -A SERVICE_ENV_TARGET=( ["passenger_portal"]="apps/edr-passenger-web/portal/.env" ["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env" ["payment_api"]="apps/edr-payment-api/.env" + ["synapse"]="infrastructure/matrix/synapse/.env" + # element_web holds no secrets, but its config.json is rendered at container + # start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL + # (see infrastructure/matrix/element), so it needs an env file like the rest — + # plus the PORT line every service env is required to carry. + ["element_web"]="infrastructure/matrix/element/.env" ) for service in "$@"; do diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index 1e9a1ff52..4fb7fbe6b 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -32,9 +32,9 @@ declare -A SERVICE_ENV_TARGET=( ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" ["payment-api"]="apps/edr-payment-api/.env" ["synapse"]="infrastructure/matrix/synapse/.env" - # element-web has no runtime secrets (its config.json is baked into the - # image), but the sync step still runs unconditionally per service and - # needs a PORT= line to compute ELEMENT_WEB_PORT for docker compose. + # element-web holds no secrets, but its config.json is rendered at container + # start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL, + # and the sync step needs a PORT= line to compute ELEMENT_WEB_PORT anyway. ["element-web"]="infrastructure/matrix/element/.env" ) From 7946c1632747d4e72c354fc1d8cedd29af60184c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 14:05:47 +0000 Subject: [PATCH 04/60] feat(eims): bake in a starter table of Ethiopia region/zone/woreda codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EIMS_BUYER_REGION_CODES/WEREDA_CODES/CITY_CODES were hand-maintained, empty-by-default env vars — every buyer from a not-yet-seen area crashed filing until someone found the MoR code and redeployed. Happened three times in one afternoon (2026-08-17): Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code were all unset on the triaplc.com deployment. Ethiopia's administrative divisions are fixed reference data, not buyer-specific config. Added ethiopia-geo-codes.ts, a static table (source: ethiopia_administrative_hierarchy_master.csv, supplied 2026-08-17 — a representative sample, not all ~1000 real woredas) merged in as the fallback under the existing env vars, which still win on a name collision — for a quick correction without a redeploy, or a buyer profile spelled differently than the table (already hit: DB has zone "Fafen", official spelling "Fafan"). Known limitation, documented in the file: zone/woreda names aren't always unique nationwide (e.g. "North Shewa" is both an Amhara and an Oromia zone) and Company stores region/zone/woreda as three independent strings with no parent linkage, so a flat name map can't always disambiguate. Only affects the optional City field — never blocks filing, unlike Region/Wereda. --- .../src/config/eims.config.spec.ts | 44 ++++++ .../edr-freight-api/src/config/eims.config.ts | 10 +- .../src/config/ethiopia-geo-codes.ts | 139 ++++++++++++++++++ 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/config/ethiopia-geo-codes.ts diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts index a6aa3895d..60ee33e1b 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -70,3 +70,47 @@ describe("eims.config — private key / certificate resolution", () => { ); }); }); + +describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => { + it("resolves a known region/wereda/zone with no env var set at all", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + const cfg = eimsConfigFactory(); + expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05"); + expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02"); + expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01"); + }, + ); + }); + + it("an env var entry overrides the baked-in code for the same name", () => { + withEnv( + { + ...REQUIRED, + EIMS_PRIVATE_KEY: "x", + EIMS_CERTIFICATE_PATH: "/dev/null", + EIMS_BUYER_REGION_CODES: "Somali=99", + }, + () => { + expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99"); + }, + ); + }); + + it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => { + withEnv( + { + ...REQUIRED, + EIMS_PRIVATE_KEY: "x", + EIMS_CERTIFICATE_PATH: "/dev/null", + EIMS_BUYER_CITY_CODES: "Fafen=01", + }, + () => { + const codes = eimsConfigFactory().invoice.buyerCityCodes; + expect(codes.Fafen).toBe("01"); + expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it + }, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 9d99ae455..e5530eaf2 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -1,5 +1,7 @@ import { registerAs } from "@nestjs/config"; +import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes"; + /** * Ethiopian MoR EIMS e-invoicing gateway. * @@ -258,9 +260,11 @@ export default registerAs("eims", (): EimsConfig => { unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), - buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), - buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), - buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES), + // Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a + // deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts. + buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) }, + buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) }, + buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) }, taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), diff --git a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts new file mode 100644 index 000000000..f7257e045 --- /dev/null +++ b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts @@ -0,0 +1,139 @@ +/** + * MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under + * `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest + * match to EIMS's "City", per `eims-invoice.mapper.ts`). + * + * Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until + * someone hunted down the code and added it to an env var by hand — happened three times in one + * afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code + * itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not + * something that should be maintained reactively per buyer. Source: `ethiopia_administrative_ + * hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region, + * not all ~1000 real woredas), extend as new gaps surface. + * + * The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction + * without a redeploy, or a name spelled differently in a buyer's profile than in this table (already + * hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is + * case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer + * actually resolves; this table mainly helps the *next* buyer whose profile spelling matches). + * + * ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names + * are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an + * Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings, + * no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data + * wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike + * Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings; + * out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists. + */ +const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [ + ["Tigray", "Western Tigray", "Humera", "01", "01", "01"], + ["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"], + ["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"], + ["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"], + ["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"], + ["Tigray", "Central Tigray", "Axum", "01", "03", "01"], + ["Tigray", "Central Tigray", "Adwa", "01", "03", "02"], + ["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"], + ["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"], + ["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"], + ["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"], + ["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"], + ["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"], + ["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"], + ["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"], + ["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"], + ["Amhara", "North Gondar", "Debark", "03", "01", "01"], + ["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"], + ["Amhara", "North Wollo", "Woldiya", "03", "03", "01"], + ["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"], + ["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"], + ["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"], + ["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"], + ["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"], + ["Amhara", "Awi", "Injibara", "03", "09", "01"], + ["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"], + ["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"], + ["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"], + ["Oromia", "North Shewa", "Fiche", "04", "01", "01"], + ["Oromia", "South West Shewa", "Waliso", "04", "02", "01"], + ["Oromia", "East Shewa", "Adama Town", "04", "03", "01"], + ["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"], + ["Oromia", "West Shewa", "Ambo", "04", "04", "01"], + ["Oromia", "Arsi", "Asella", "04", "05", "01"], + ["Oromia", "West Arsi", "Shashemene", "04", "06", "01"], + ["Oromia", "Bale", "Robe", "04", "07", "01"], + ["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"], + ["Oromia", "West Hararghe", "Chiro", "04", "09", "01"], + ["Oromia", "Jimma", "Jimma Town", "04", "10", "01"], + ["Oromia", "Illubabor", "Mettu", "04", "11", "01"], + ["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"], + ["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"], + ["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"], + ["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"], + ["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"], + ["Oromia", "Borena", "Yabelo", "04", "17", "01"], + ["Oromia", "Guji", "Negele Borana", "04", "18", "01"], + ["Oromia", "West Guji", "Bule Hora", "04", "19", "01"], + ["Oromia", "East Bale", "Ginir", "04", "20", "01"], + ["Oromia", "Sheger City", "Sululta", "04", "21", "01"], + ["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"], + ["Somali", "Fafan", "Jijiga Town", "05", "01", "02"], + ["Somali", "Fafan", "Awbare", "05", "01", "03"], + ["Somali", "Sitti", "Shinile", "05", "02", "01"], + ["Somali", "Erer", "Fiq", "05", "03", "01"], + ["Somali", "Jarar", "Degehabur", "05", "04", "01"], + ["Somali", "Nogob", "Segeg", "05", "05", "01"], + ["Somali", "Korahe", "Kebridehar", "05", "06", "01"], + ["Somali", "Shabelle", "Gode", "05", "07", "01"], + ["Somali", "Afder", "Afder Woreda", "05", "08", "01"], + ["Somali", "Liben", "Filtu", "05", "09", "01"], + ["Somali", "Dhawa", "Mubarak", "05", "10", "01"], + ["Somali", "Dollo", "Warder", "05", "11", "01"], + ["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"], + ["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"], + ["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"], + ["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"], + ["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"], + ["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"], + ["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"], + ["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"], + ["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"], + ["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"], + ["Gambela", "Nuer", "Lare", "08", "02", "01"], + ["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"], + ["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"], + ["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"], + ["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"], + ["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"], + ["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"], + ["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"], + ["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"], + ["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"], + ["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"], + ["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"], + ["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"], + ["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"], + ["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"], + ["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"], + ["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"], + ["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"], + ["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"], + ["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"], + ["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"], + ["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"], +]; + +/** First occurrence wins on a name collision — see the class comment. */ +const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record => { + const map: Record = {}; + for (const row of ROWS) { + const [name, code] = pick(row); + if (!(name in map)) map[name] = code; + } + return map; +}; + +export const ETHIOPIA_REGION_CODES: Record = buildMap((r) => [r[0], r[3]]); +/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */ +export const ETHIOPIA_ZONE_CODES: Record = buildMap((r) => [r[1], r[4]]); +export const ETHIOPIA_WOREDA_CODES: Record = buildMap((r) => [r[2], r[5]]); From 7d8ab932c201d5fbd379f44d8e82eb42fbf89629 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 17:33:58 +0000 Subject: [PATCH 05/60] feat(eims): implement POST /v1/bulkCancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoint: POST invoices/eims/bulk-cancel, body { items: [{invoiceId, reasonCode, remark?}] }. Same eimsCancel permission as single cancel — a batch-scale version of the same irreversible-at-MoR action, not a new capability. Same local-eligibility doctrine as single cancel: an already-cancelled or never-registered invoice is refused right here, no HTTP call, before it gets a seat in the batch. Only genuinely eligible invoices go into the one /v1/bulkCancel request; every outcome (local refusal or MoR's own per-IRN result) is reported back independently — one invoice failing never blocks the rest. MoR's bulk response mixes success and error shapes in the same array, disambiguated by Status (capital, error) vs status (lowercase, success) — matched back to our invoices by IRN. Notably the bulk success shape carries no cancellationDate at all, unlike single cancel. Left out of this pass: bulkRegister. It's async (returns only a conversationId immediately, results arrive via a webhook callback we don't have yet) and needs manual counter/previousIrn management per the collection's own docs — a materially different reservation model than today's single-invoice TX1/TX2 pattern. Scoping that is a separate, bigger piece of work. --- .../dto/bulk-cancel-eims-registration.dto.ts | 33 ++++++ .../eims/eims-cancellation.service.spec.ts | 104 +++++++++++++++++ .../modules/eims/eims-cancellation.service.ts | 106 +++++++++++++++++- .../modules/eims/eims-invoice.controller.ts | 12 ++ .../modules/eims/eims-registration.types.ts | 40 +++++++ 5 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts diff --git a/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts new file mode 100644 index 000000000..c4782782c --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator"; + +export class BulkCancelEimsItemDto { + @ApiProperty({ description: "Invoice ID to cancel." }) + @IsUUID() + invoiceId!: string; + + @ApiProperty({ + description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).', + example: "1", + }) + @IsString() + @Length(1, 8) + reasonCode!: string; + + @ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" }) + @IsOptional() + @IsString() + @Length(0, 500) + remark?: string; +} + +/** `POST invoices/eims/bulk-cancel` body — see `EimsCancellationService.cancelBulkWithEims`. */ +export class BulkCancelEimsRegistrationDto { + @ApiProperty({ type: [BulkCancelEimsItemDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => BulkCancelEimsItemDto) + items!: BulkCancelEimsItemDto[]; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts index 6bd9b04b3..b9fdf62df 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts @@ -9,7 +9,9 @@ import { EimsApiException } from "./eims.errors"; import { EimsInvoiceStatus } from "./eims-registration.types"; const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222"; const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; +const OTHER_IRN = "0af579eaef6f1e2d39fa77bd21cf8ecc64e26869275ae1c04eaa9ffea78b6c06"; const invoiceRow = (over: Partial = {}): Invoice => ({ @@ -153,3 +155,105 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => { expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); }); }); + +describe("EimsCancellationService.cancelBulkWithEims", () => { + it("cancels every eligible invoice in one call, matching results back by IRN", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]); + const postBearer = jest.fn().mockResolvedValue({ + statusCode: 200, + body: [ + { id: 1, tin: "t", status: "C", mode: "bulk", Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" }, + { id: 2, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" }, + ], + }); + + const results = await build(db, postBearer).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "6", remark: "x" }, + ]); + + expect(postBearer).toHaveBeenCalledWith("/v1/bulkCancel", [ + { Irn: IRN, ReasonCode: "1", Remark: "" }, + { Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" }, + ]); + expect(results).toEqual([ + { invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") }, + { invoiceId: OTHER_INVOICE_ID, success: true, message: expect.stringContaining("cancelled") }, + ]); + expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); + expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); + // Bulk success carries no cancellationDate at all, unlike single cancel. + expect(db.invoices.get(INVOICE_ID)?.eimsCancellationDate).toBeNull(); + }); + + it("refuses an already-cancelled or never-registered invoice locally — never sent to MoR", async () => { + const db = new FakeDb([ + invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled }), + invoiceRow({ id: OTHER_INVOICE_ID, eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null }), + ]); + const postBearer = jest.fn(); + + const results = await build(db, postBearer).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "1" }, + ]); + + expect(postBearer).not.toHaveBeenCalled(); + expect(results).toEqual([ + { invoiceId: INVOICE_ID, success: false, message: expect.stringContaining("already cancelled") }, + { invoiceId: OTHER_INVOICE_ID, success: false, message: expect.stringContaining("never registered") }, + ]); + }); + + it("a mix of MoR success and rejection only updates the succeeding invoice", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]); + const postBearer = jest.fn().mockResolvedValue({ + statusCode: 200, + body: [ + { id: 1, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" }, + { Status: "Processing_Error", msg: "IRN already Canceled.", Irn: OTHER_IRN }, + ], + }); + + const results = await build(db, postBearer).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "1" }, + ]); + + expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); + expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered); + expect(results).toEqual([ + { invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") }, + { invoiceId: OTHER_INVOICE_ID, success: false, message: "IRN already Canceled." }, + ]); + }); + + it("makes no HTTP call at all when every item fails the local eligibility check", async () => { + const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]); + const postBearer = jest.fn(); + + await build(db, postBearer).cancelBulkWithEims([{ invoiceId: INVOICE_ID, reasonCode: "1" }]); + + expect(postBearer).not.toHaveBeenCalled(); + }); + + it("notifies the buyer only for invoices that actually got cancelled", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]); + db.companyContact = { phone: "+251911000000", email: null }; + const directSend = jest.fn().mockResolvedValue(undefined); + const postBearer = jest.fn().mockResolvedValue({ + statusCode: 200, + body: [ + { status: "C", Irn: IRN }, + { Status: "Processing_Error", msg: "boom", Irn: OTHER_IRN }, + ], + }); + + await build(db, postBearer, directSend).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "1" }, + ]); + + expect(directSend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts index 9aa525ae3..7f8605c5f 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts @@ -6,7 +6,15 @@ import { Invoice } from "../billing/entities/invoice.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { sendCompanyChannels } from "../notifications/notify-company.util"; import { EimsClientService } from "./eims-client.service"; -import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types"; +import { + EimsBulkCancelItemResult, + EimsBulkCancelRequest, + EimsBulkCancelResponse, + EimsCancelRequest, + EimsCancelResponse, + EimsInvoiceStatus, + EimsInvoiceStatusView, +} from "./eims-registration.types"; import { toEimsInvoiceStatusView } from "./eims-invoice-view.util"; /** @@ -92,6 +100,102 @@ export class EimsCancellationService { return this.getEimsCancellationStatus(invoiceId); } + /** + * `POST /v1/bulkCancel` — one MoR call for every eligible invoice in `items`, matching the + * collection's own shape (an array in, an array of mixed success/error results back). + * + * Same local-eligibility doctrine as `cancelInvoiceWithEims`, applied per item before anything + * goes to MoR: an already-cancelled or never-registered invoice is refused right here (no HTTP + * call, no seat in the batch) rather than sent and rejected remotely. Only genuinely eligible + * invoices are batched into the one `/v1/bulkCancel` request; everything else is reported back + * immediately. + * + * ponytail: the eligibility pass is per-invoice transactions, not one covering the whole batch — + * same reasoning as the single-cancel path (cancel is idempotent at MoR, so a lock held across + * every item for the whole call isn't needed for correctness, only for avoiding a wasted call on + * an item that's already ineligible). + */ + async cancelBulkWithEims( + items: Array<{ invoiceId: string; reasonCode: string; remark?: string }>, + ): Promise { + const results = new Map(); + const eligible: Array<{ invoice: Invoice; reasonCode: string; remark?: string }> = []; + + for (const item of items) { + try { + const invoice = await this.dataSource.transaction(async (manager) => { + const inv = await this.lockInvoice(manager, item.invoiceId); + if (inv.eimsStatus === EimsInvoiceStatus.Cancelled) { + throw new ConflictException( + `Invoice ${inv.invoiceNumber} was already cancelled with EIMS${inv.eimsCancellationDate ? ` (${inv.eimsCancellationDate})` : ""}.`, + ); + } + if (!inv.eimsIrn) { + throw new BadRequestException( + `Invoice ${inv.invoiceNumber} was never registered with EIMS — nothing to cancel.`, + ); + } + return inv; + }); + eligible.push({ invoice, reasonCode: item.reasonCode, remark: item.remark }); + } catch (err) { + results.set(item.invoiceId, { + invoiceId: item.invoiceId, + success: false, + message: (err as Error).message, + }); + } + } + + if (eligible.length > 0) { + const request: EimsBulkCancelRequest = eligible.map((e) => ({ + Irn: e.invoice.eimsIrn!, + ReasonCode: e.reasonCode, + Remark: e.remark ?? "", + })); + // Outside any transaction — no DB lock held across the wire, same as single cancel. + const response = await this.client.postBearer( + "/v1/bulkCancel", + request, + ); + const byIrn = new Map((response?.body ?? []).map((entry) => [entry.Irn, entry])); + + for (const { invoice, reasonCode, remark } of eligible) { + const entry = byIrn.get(invoice.eimsIrn!); + const failed = !entry || "Status" in entry; + if (failed) { + const message = entry && "msg" in entry ? entry.msg : "EIMS bulk cancel returned no result for this invoice."; + this.logger.warn(`Invoice ${invoice.invoiceNumber} bulk cancel failed: ${message}`); + results.set(invoice.id, { invoiceId: invoice.id, success: false, message }); + continue; + } + + await this.dataSource.transaction(async (manager) => { + const fresh = await this.lockInvoice(manager, invoice.id); + // Re-checked under lock: a concurrent call may have already recorded this cancellation. + if (fresh.eimsStatus === EimsInvoiceStatus.Cancelled) return; + await manager.update(Invoice, invoice.id, { + eimsStatus: EimsInvoiceStatus.Cancelled, + eimsCancelledAt: new Date(), + // The bulk success shape carries no cancellationDate at all, unlike single cancel. + eimsCancellationDate: null, + eimsCancellationReasonCode: reasonCode, + eimsCancellationRemark: remark ?? null, + }); + }); + this.logger.log(`Invoice ${invoice.invoiceNumber} cancelled with EIMS via bulk (IRN ${invoice.eimsIrn})`); + await this.notifyBuyer(invoice); + results.set(invoice.id, { + invoiceId: invoice.id, + success: true, + message: `Invoice ${invoice.invoiceNumber} cancelled with EIMS.`, + }); + } + } + + return items.map((item) => results.get(item.invoiceId)!); + } + async getEimsCancellationStatus(invoiceId: string): Promise { const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } }); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index 7d417551f..11eb214dc 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -5,6 +5,7 @@ import type { Response } from "express"; import { BookingStaff } from "../../common/booking-guards"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { sendPdf } from "../billing/billing.controller"; +import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto"; import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; @@ -89,6 +90,17 @@ export class EimsInvoiceController { return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark); } + @Post("eims/bulk-cancel") + @BookingStaff(FREIGHT_PERMS.invoices.eimsCancel) + @ApiOperation({ + summary: + "Cancel multiple invoices' registered EIMS documents in one call. Each invoice's outcome is " + + "reported independently — one failure never blocks the rest.", + }) + bulkCancel(@Body() dto: BulkCancelEimsRegistrationDto) { + return this.cancellation.cancelBulkWithEims(dto.items); + } + @Post(":id/eims/receipt/sales") @BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister) @ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" }) diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts index 8094d0e74..60941825c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -89,6 +89,46 @@ export interface EimsCancelResponse { body?: EimsCancelResponseBody; } +/** `POST /v1/bulkCancel` — an array of the same `Irn`/`ReasonCode`/`Remark` shape as single cancel. */ +export type EimsBulkCancelRequest = EimsCancelRequest[]; + +/** + * One element of a `/v1/bulkCancel` response array — MoR mixes success and error shapes in the same + * array, one entry per submitted IRN, disambiguated by `Status` (capital, error) vs `status` + * (lowercase, success — always `"C"`). Unlike single cancel, a bulk success carries no + * `cancellationDate` at all. + */ +export interface EimsBulkCancelSuccessItem { + id?: number; + tin?: string; + status: string; + mode?: string; + Irn: string; + ReasonCode?: string; + Remark?: string; +} + +export interface EimsBulkCancelErrorItem { + Status: string; + msg: string; + Irn: string; +} + +export type EimsBulkCancelResponseItem = EimsBulkCancelSuccessItem | EimsBulkCancelErrorItem; + +export interface EimsBulkCancelResponse { + statusCode?: number; + message?: string; + body?: EimsBulkCancelResponseItem[]; +} + +/** One invoice's outcome from `cancelBulkWithEims` — local eligibility failure or MoR's own result. */ +export interface EimsBulkCancelItemResult { + invoiceId: string; + success: boolean; + message: string; +} + /** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */ export interface EimsInvoiceError { kind: string; From e67ccbb9cd1aa81b511c85a6ce8bbb5789bad573 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 18:10:55 +0000 Subject: [PATCH 06/60] fix(eims): stop sending our internal fee-basis tag as MoR's ItemList Unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live 2026-08-17 on INV-20260817-00008: MoR rejected the document with a SCHEMA ERROR on ItemList[0].Unit — 'PER_CONTAINER' (from the line's own metadata.unit) fails MoR's enum (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG), its 8-char max, and its ^[A-Za-z]{3,8}$ regex all at once. line.metadata.unit is our own fee-basis tag (PER_CONTAINER/PER_TON/ PER_ITEM — how a charge is computed) and was never a MoR unit of measure; the mapper was reusing the same field name for two unrelated concepts. Every line now sends the single configured EIMS_UNIT_DEFAULT instead of guessing a per-line value that doesn't exist in MoR's vocabulary. --- .../src/modules/billing/eims-invoice.mapper.spec.ts | 4 +++- .../src/modules/billing/eims-invoice.mapper.ts | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 6d58a7bec..75d39a500 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -151,7 +151,9 @@ describe("toEimsInvoice", () => { // EimsLineTax.discount comment in eims-invoice.mapper.ts. Discount: 25, TotalLineAmount: 1050, - Unit: "CTR", + // Not "CTR" from the line's metadata.unit — that's our internal fee-basis tag, not a MoR + // unit of measure, and is never read for this field (see the mapper's own comment). + Unit: "PCS", }); expect(doc.ValueDetails).toEqual({ Discount: null, diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index b8755c709..c72e61c7a 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -463,7 +463,13 @@ export function toEimsInvoice( const PreTaxValue = round2(num(line.amount)); const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100); const ExciseTaxValue = round2(tax.exciseTaxValue); - const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault; + // `line.metadata.unit` is our own fee-basis tag (PER_CONTAINER/PER_TON/PER_ITEM — how a charge + // is computed, see the fee-rule docs), never a MoR unit of measure — sending it as-is here + // (confirmed live 2026-08-17: "PER_CONTAINER" fails Unit's enum, its 8-char max, and its regex + // all at once) is what a prior version of this mapper did by mistake. MoR's own enum + // (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG) has no freight-shipment concept at all, so every line + // uses the single configured default rather than guessing a per-line value that doesn't exist. + const unit = context.unitDefault; return { Discount: round2(tax.discount), From a9eac93fa3ea1afb7f4e1c4f9e00b5d57f33ba33 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 18 Aug 2026 10:16:59 +0300 Subject: [PATCH 07/60] fix: ( supplementary-charges ) pay in the selected method's currency, add CAC Bank and CBE --- .../payments/internal-payments.controller.ts | 5 + .../modules/payments/payments.controller.ts | 59 +++ .../modules/payments/payments.service.spec.ts | 76 ++++ .../src/modules/payments/payments.service.ts | 66 +++ .../payments/supplementary-charges.service.ts | 201 ++++++++- .../payments/supplementary-charges.spec.ts | 245 +++++++++++ .../src/app/pay-balance/[token]/page.tsx | 412 +++++++++++++++++- 7 files changed, 1047 insertions(+), 17 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts index be46135a1..1b82389fb 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -58,6 +58,11 @@ export class InternalPaymentsController { if (request.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) { return this.paymentsService.billQueryExcessBaggage(request.referenceId); } + if (request.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) { + return this.paymentsService.billQuerySupplementaryCharge( + request.referenceId, + ); + } return this.paymentsService.billQuery(request.referenceId); } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 858d108fe..57db52fe9 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -57,6 +57,18 @@ class WaiveSupplementaryChargeDto { class PaySupplementaryChargeDto { @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; @ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto; + @ApiPropertyOptional({ + description: + 'Payer account / mobile number. Required for the push-debit methods: CAC_BANK (the bank ' + + 'SMSes a one-time password to it) and EBIRR (the wallet pushes a USSD PIN prompt to it).', + example: '77123456', + }) + @IsOptional() @IsString() payerAccount?: string; +} + +class ConfirmSupplementaryOtpDto { + @ApiProperty({ description: 'One-time password the payer received by SMS (CAC Bank).', example: '4530' }) + @IsString() otp: string; } @ApiTags("Payment") @@ -413,6 +425,52 @@ export class PaymentsController { return this.supplementaryService.getByToken(token); } + @Get('supplementary/by-token/:token/amount') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Quote a supplementary charge in a payment method’s settlement currency (public)', + description: + 'Returns what the given method would debit, converted from the charge’s stored ETB amount ' + + 'to that method’s settlement currency (WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian ' + + 'wallets in ETB). The self-pay page quotes this before the payer commits; paying recomputes ' + + 'it identically.', + }) + @ApiQuery({ name: 'method', required: true, example: 'WAAFI' }) + quoteSupplementaryAmount( + @Param('token') token: string, + @Query('method') method: string, + ) { + return this.supplementaryService.quoteAmount(token, method); + } + + @Get('supplementary/by-token/:token/status') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Poll a supplementary charge’s settlement status (public)', + description: + 'Reports the charge’s current status without the payability gate on the by-token lookup, ' + + 'so a page can watch for settlement that happens out of band (a CBE bill paid at a branch, ' + + 'or a redirect payment confirmed by webhook).', + }) + getSupplementaryStatus(@Param('token') token: string) { + return this.supplementaryService.getStatus(token); + } + + @Post('supplementary/by-token/:token/confirm') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Confirm an OTP-debit balance payment (CAC Bank, public — self-pay)', + description: + 'Submits the one-time password the payer received by SMS. A wrong or expired OTP returns ' + + '400 and the payment stays open for retry.', + }) + confirmSupplementaryOtp( + @Param('token') token: string, + @Body() dto: ConfirmSupplementaryOtpDto, + ) { + return this.supplementaryService.confirmOtp(token, dto.otp); + } + @Post('supplementary/by-token/:token/pay') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' }) @@ -430,6 +488,7 @@ export class PaymentsController { dto.method, dto.platform, resolveAllowedOrigin(origin, referer, frontendBaseUrl), + dto.payerAccount, ); } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 36b062261..71efe6546 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -50,6 +50,10 @@ describe("PaymentsService", () => { findUnique: jest.fn(), update: jest.fn(), }, + supplementaryCharge: { + findUnique: jest.fn(), + update: jest.fn(), + }, currencyExchangeRate: { findFirst: jest.fn(), }, @@ -758,4 +762,76 @@ describe("PaymentsService", () => { ).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" }); }); }); + /** + * The live hop CBE makes while a teller is on the line, for a balance bill. Same + * double-payment guard as bookings and baggage: anything other than stillPayable=true makes + * CBE refuse the debit. + */ + describe("billQuerySupplementaryCharge", () => { + const payable = { + id: "sc-1", + amountMinor: 100_000, + status: "PENDING", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + booking: { + bookingRef: "BAL-001", + seats: [{ leg: 1, passengerName: "Abebe Kebede" }], + passenger: { user: { fullName: "Account Holder" } }, + }, + }; + + it("reports a pending charge as payable, in ETB, with the passenger name", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue(payable); + const result = await service.billQuerySupplementaryCharge("sc-1"); + expect(result).toMatchObject({ + stillPayable: true, + currency: "ETB", + currentAmountMinor: 1000, + payerName: "Abebe Kebede", + }); + expect(result.paymentReason).toContain("BAL-001"); + }); + + it("refuses an already paid charge", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "PAID" }); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({ + stillPayable: false, + reason: "ALREADY_PAID", + }); + }); + + it("refuses a waived charge", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "WAIVED" }); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({ + stillPayable: false, + reason: "CANCELLED", + }); + }); + + it("refuses within the settle margin so a debit cannot land after expiry", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() + 5_000), + }); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({ + stillPayable: false, + reason: "EXPIRED", + }); + }); + + it("treats a null expiry as an open-ended debt, still payable", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, expiresAt: null }); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({ + stillPayable: true, + }); + }); + + it("reports NOT_FOUND for a bill whose charge is gone", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue(null); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toEqual({ + stillPayable: false, + reason: "NOT_FOUND", + }); + }); + }); }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 1415c1dcc..b58b56ade 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -600,6 +600,72 @@ export class PaymentsService { return { ...base, stillPayable: true, reason: null }; } + /** + * Bill-query for a supplementary charge — the same live "still payable?" hop as bookings, + * against `SupplementaryCharge`. This is the double-payment guard for balance bills: once the + * charge is paid, waived or lapsed, CBE is told to refuse the debit. + * + * The charge's own 72-hour `expiresAt` is the deadline. It is nullable — a charge raised with + * no expiry is an open-ended debt and stays payable indefinitely, which is the intended reading + * of a null here rather than an immediate refusal. + */ + async billQuerySupplementaryCharge( + chargeId: string, + ): Promise { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { id: chargeId }, + include: { + booking: { + include: { seats: true, passenger: { include: { user: true } } }, + }, + }, + }); + // A bill reference we issued whose charge has since been deleted — a data problem, not a + // customer-facing cancellation. + if (!charge) return { stillPayable: false, reason: "NOT_FOUND" }; + + const base = { + payerName: + charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ?? + charge.booking?.seats?.[0]?.passengerName ?? + charge.booking?.passenger?.user?.fullName ?? + null, + // The charge is raised in ETB and CBE settles ETB only, so no conversion applies. + currentAmountMinor: this.currencyService.displayMinorToChargeMajor( + charge.amountMinor, + "ETB", + ), + currency: "ETB", + // Rendered beside the amount on CBE's confirmation screen. The booking ref is on the + // passenger's ticket, so they can match the two before confirming. + paymentReason: `Outstanding balance — booking ${ + charge.booking?.bookingRef ?? "" + }`.trim(), + }; + + if (charge.status === "PAID") { + return { ...base, stillPayable: false, reason: "ALREADY_PAID" }; + } + // Staff wrote the balance off; from the payer's side the debt is gone. + if (charge.status === "WAIVED") { + return { ...base, stillPayable: false, reason: "CANCELLED" }; + } + // Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline + // that the sweep expires the intent before the capture is registered. + if ( + charge.status === "EXPIRED" || + (charge.expiresAt && + charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < + Date.now()) + ) { + return { ...base, stillPayable: false, reason: "EXPIRED" }; + } + if (charge.status !== "PENDING") { + return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; + } + return { ...base, stillPayable: true, reason: null }; + } + /** * The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's * origin-segment time and that stop's own check-in window, falling back to the route default. diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index 079792366..b0bfabf09 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -4,11 +4,35 @@ import { AuditService } from '../../common/audit.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { EmailClientService } from '../notifications/email-client.service'; import { PaymentClientService } from './payment-client.service'; -import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types'; +import { CurrencyService } from '../currency/currency.service'; +import { + PaymentReferenceType, + PaymentService as PaymentServiceEnum, + ProviderMethod, + ProviderPaymentStatus, +} from '@edr/types'; import { PaymentPlatformDto } from './payments.dto'; +import { PaymentMethodType } from '@prisma/client'; const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours +/** + * WALLET is an internal balance debit handled inside this app, not a provider — the payment + * microservice rejects it as one. Supplementary charges have no wallet path, so it is refused up + * front with a message the payer can act on rather than a 502 from the gateway layer. + */ +const UNSUPPORTED_METHODS = new Set([PaymentMethodType.WALLET]); + +/** + * Push-debit methods charge an account we must be told up front — CAC Bank SMSes a one-time + * password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could + * collect the number later (mirrors PaymentsService and ExcessBaggageService). + */ +const METHODS_REQUIRING_PAYER_ACCOUNT = new Set([ + PaymentMethodType.CAC_BANK, + PaymentMethodType.EBIRR, +]); + @Injectable() export class SupplementaryChargesService { private readonly logger = new Logger(SupplementaryChargesService.name); @@ -19,6 +43,7 @@ export class SupplementaryChargesService { private smsClient: SmsClientService, private emailClient: EmailClientService, private paymentClient: PaymentClientService, + private currencyService: CurrencyService, ) {} async create(dto: { @@ -117,14 +142,161 @@ export class SupplementaryChargesService { return updated; } + /** + * What the payer is actually charged when settling this charge with `method`. + * + * The charge is raised in ETB, but the selected method settles in its own currency — WAAFI and + * D-Money in DJF, CARD in USD, the Ethiopian wallets in ETB — recorded on the PaymentMethod row. + * The payment microservice is currency-agnostic and hands whatever it is given straight to the + * gateway, so the ETB->settlement conversion has to happen here or the provider is asked to + * debit an ETB number labelled as its own currency. + * + * Both the quote shown to the payer and the amount sent to the provider come through this one + * method, so the price on the button and the price debited cannot drift apart. + */ + private async resolveChargeAmount( + charge: { amountMinor: number; currency: string }, + method: string, + ): Promise<{ amount: number; currency: string }> { + if (UNSUPPORTED_METHODS.has(method)) { + throw new BadRequestException( + `${method} is not available for balance payments`, + ); + } + + const paymentMethod = await this.prisma.paymentMethod.findUnique({ + where: { type: method as PaymentMethodType }, + }); + // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8) — never converted. + const chargeCurrency = + method === PaymentMethodType.CBE_BILL + ? 'ETB' + : (paymentMethod?.currency ?? charge.currency).toUpperCase(); + + // Applies the target currency's own precision — DJF rounds to whole francs, ETB/USD to cents. + const amount = await this.currencyService.convertMinorToChargeMajor( + charge.amountMinor, + charge.currency, + chargeCurrency, + ); + return { amount, currency: chargeCurrency }; + } + + /** + * Price quote for the pay page: what `method` would debit, in that method's settlement + * currency. The payer sees this before committing, and pay() recomputes it the same way. + */ + async quoteAmount(token: string, method: string) { + const charge = await this.getByToken(token); + const { amount, currency } = await this.resolveChargeAmount(charge, method); + return { chargeId: charge.id, method, currency, amount }; + } + + /** + * Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid, + * expired or waived charge — reporting those states is the entire point. A CBE bill can settle + * long after the payer closed the tab, and redirect methods only converge when the settlement + * event lands, so the page needs something it can watch. + */ + async getStatus(token: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { paymentToken: token }, + select: { + id: true, + status: true, + paidAt: true, + amountMinor: true, + currency: true, + expiresAt: true, + }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + return { + chargeId: charge.id, + status: charge.status, + paid: charge.status === 'PAID', + paidAt: charge.paidAt, + amountMinor: charge.amountMinor, + currency: charge.currency, + expiresAt: charge.expiresAt, + }; + } + + /** + * Full_Name for CBE's confirmation screen — mandatory in its envelope. The traveller the balance + * is owed against: lead passenger on the booking, falling back to the account holder. + */ + private async resolvePayerName(bookingId: string): Promise { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { seats: true, passenger: { include: { user: true } } }, + }); + if (!booking) return null; + return ( + booking.seats?.find((s: any) => s.leg === 1)?.passengerName ?? + booking.seats?.[0]?.passengerName ?? + booking.passenger?.user?.fullName ?? + null + ); + } + + /** + * Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the + * payerAccount given at pay(); this forwards it to the payment service and marks the charge paid + * when the debit settles. A wrong or expired OTP bubbles up as a 400 and the intent stays open, + * so the payer can simply re-enter it. + * + * Deliberately reads the charge directly rather than through getByToken: the bank is already + * holding a debit against this payer, and refusing to submit their OTP because the link TTL + * lapsed while they read the SMS would strand a payment that is mid-flight. + */ + async confirmOtp(token: string, otp: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { paymentToken: token }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'PAID') { + return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true }; + } + + const snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.SUPPLEMENTARY_CHARGE, + charge.id, + ); + if (!snapshot) { + throw new NotFoundException('No active payment to confirm for this charge'); + } + + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markPaid(charge.id, confirmed.providerTxnId); + } + + return { + chargeId: charge.id, + status: confirmed.status, + alreadyPaid: false, + }; + } + async pay( token: string, method: string, platform?: PaymentPlatformDto, requestOrigin?: string | null, + payerAccount?: string, ) { const charge = await this.getByToken(token); // validates status/expiry + if (METHODS_REQUIRING_PAYER_ACCOUNT.has(method) && !payerAccount?.trim()) { + throw new BadRequestException( + `payerAccount (mobile number) is required for ${method}`, + ); + } + const paymentMethod = method as ProviderMethod; // Self-pay links are opened on whichever portal domain the recipient used // (bookingedr.et vs passenger.edrsc.com), so the return pages must live on @@ -135,15 +307,38 @@ export class SupplementaryChargesService { const returnUrl = `${portalUrl}/pay-balance/${token}/success`; const failureUrl = `${portalUrl}/pay-balance/${token}/failed`; + const { amount, currency } = await this.resolveChargeAmount(charge, method); + + // CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's + // system until someone pays it. It needs a real deadline and a payer name (Full_Name is + // mandatory in CBE's envelope) rather than the redirect flow's session semantics. + // + // Unlike an excess baggage charge (30-minute link TTL), this charge already carries a 72-hour + // deadline of its own, which is a sane bill lifetime — so it is passed straight through with + // no extension. That deadline is what stops the reconciliation sweep from expiring the intent + // early (CBE_IMPLEMENTATION_PLAN.md §6.4). A charge with no expiry at all yields no intent + // expiry either, which is correct: an open-ended debt backs an open-ended bill. + let payerName: string | undefined; + let expiresAt: string | undefined; + if (method === PaymentMethodType.CBE_BILL) { + expiresAt = charge.expiresAt?.toISOString(); + payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined; + } + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE, referenceId: charge.id, orderRef: `SC-${charge.id.substring(0, 8)}`, - amountMinor: charge.amountMinor / 100, - currency: charge.currency, + // `amountMinor` is the contract's name but its value is MAJOR units — the provider layer + // charges it verbatim at the currency's own precision (see PaymentIntentSnapshot). + amountMinor: amount, + currency, provider: paymentMethod, platform, + payerAccount: payerAccount?.trim() || undefined, + payerName, + expiresAt, returnUrl, failureUrl, }); diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts new file mode 100644 index 000000000..130ed1870 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts @@ -0,0 +1,245 @@ +import { BadRequestException } from '@nestjs/common'; +import { PaymentMethodType } from '@prisma/client'; +import { SupplementaryChargesService } from './supplementary-charges.service'; +import { CurrencyService } from '../currency/currency.service'; + +/** + * A supplementary charge is raised in ETB, but each payment method settles in its own currency and + * the payment microservice forwards whatever it is given straight to the gateway. These cover the + * ETB->settlement conversion, plus the two methods that could not complete at all before: CAC Bank + * (OTP debit) and CBE (inbound bill). + */ +describe('SupplementaryChargesService — payment methods', () => { + const CHARGE_ID = 'sc-1'; + const TOKEN = 'tok-1'; + + let prisma: Record; + let paymentClient: Record; + let service: SupplementaryChargesService; + let charge: any; + + const build = (rate?: { rate: number }) => { + charge = { + id: CHARGE_ID, + bookingId: 'booking-1', + amountMinor: 100_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000), + booking: { bookingRef: 'BAL-001' }, + }; + prisma = { + supplementaryCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + }, + booking: { + findUnique: jest.fn().mockResolvedValue({ + seats: [{ leg: 1, passengerName: 'Abebe Kebede' }], + passenger: { user: { fullName: 'Account Holder' } }, + }), + }, + paymentMethod: { findUnique: jest.fn() }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue(rate ?? null), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { type: 'REDIRECT', url: 'https://gw.test/pay' }, + }), + getIntentByReference: jest + .fn() + .mockResolvedValue({ intentId: 'intent-1', status: 'REQUIRES_ACTION' }), + confirmOtp: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'SUCCEEDED', + providerTxnId: 'CAC-77', + }), + }; + service = new SupplementaryChargesService( + prisma as any, + { log: jest.fn() } as any, + {} as any, + {} as any, + paymentClient as any, + new CurrencyService(prisma as any), + ); + }; + + const withMethod = (type: string, currency: string) => + prisma.paymentMethod.findUnique.mockResolvedValue({ type, currency }); + + describe('currency', () => { + it('charges an Ethiopian wallet in ETB, unconverted', async () => { + build(); + withMethod(PaymentMethodType.TELEBIRR, 'ETB'); + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.TELEBIRR); + expect(quote).toMatchObject({ currency: 'ETB', amount: 1000 }); + expect(prisma.currencyExchangeRate.findFirst).not.toHaveBeenCalled(); + }); + + it('converts to DJF and rounds to whole francs', async () => { + build({ rate: 3.25 }); + withMethod(PaymentMethodType.DMONEY, 'DJF'); + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.DMONEY); + expect(quote).toMatchObject({ currency: 'DJF', amount: 3250 }); + expect(Number.isInteger(quote.amount)).toBe(true); + }); + + it('sends the provider the converted amount, not the stored ETB total', async () => { + build({ rate: 0.018 }); + withMethod(PaymentMethodType.CARD, 'USD'); + await service.pay(TOKEN, PaymentMethodType.CARD, 'web' as any, null); + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + referenceType: 'SUPPLEMENTARY_CHARGE', + amountMinor: 18, + currency: 'USD', + }), + ); + }); + + it('quotes and charges the same figure', async () => { + build({ rate: 3.25 }); + withMethod(PaymentMethodType.WAAFI, 'DJF'); + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.WAAFI); + await service.pay(TOKEN, PaymentMethodType.WAAFI, 'web' as any, null); + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(quote.amount).toBe(sent.amountMinor); + expect(quote.currency).toBe(sent.currency); + }); + + it('refuses WALLET, which has no supplementary-charge path', async () => { + build(); + await expect( + service.quoteAmount(TOKEN, PaymentMethodType.WALLET), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('fails closed when no exchange rate is configured', async () => { + build(); + withMethod(PaymentMethodType.WAAFI, 'DJF'); + await expect( + service.pay(TOKEN, PaymentMethodType.WAAFI, 'web' as any, null), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + }); + + describe('CAC Bank OTP debit', () => { + beforeEach(() => { + build({ rate: 3.25 }); + withMethod(PaymentMethodType.CAC_BANK, 'DJF'); + }); + + it('rejects pay() without a payer mobile', async () => { + await expect( + service.pay(TOKEN, PaymentMethodType.CAC_BANK, 'web' as any, null), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('forwards the trimmed payer mobile', async () => { + await service.pay( + TOKEN, + PaymentMethodType.CAC_BANK, + 'web' as any, + null, + ' 77123456 ', + ); + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ payerAccount: '77123456', currency: 'DJF' }), + ); + }); + + it('submits the OTP against the active intent and marks the charge paid', async () => { + const result = await service.confirmOtp(TOKEN, '4530'); + expect(paymentClient.getIntentByReference).toHaveBeenCalledWith( + 'SUPPLEMENTARY_CHARGE', + CHARGE_ID, + ); + expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530'); + expect(prisma.supplementaryCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'PAID', + providerTxnId: 'CAC-77', + }), + }), + ); + expect(result).toMatchObject({ status: 'SUCCEEDED', alreadyPaid: false }); + }); + + it('leaves the charge unpaid when the OTP does not settle', async () => { + paymentClient.confirmOtp.mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + }); + await service.confirmOtp(TOKEN, '0000'); + expect(prisma.supplementaryCharge.update).not.toHaveBeenCalled(); + }); + + it('is idempotent once already paid', async () => { + prisma.supplementaryCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + }); + await expect(service.confirmOtp(TOKEN, '4530')).resolves.toMatchObject({ + alreadyPaid: true, + }); + expect(paymentClient.confirmOtp).not.toHaveBeenCalled(); + }); + }); + + describe('CBE bill', () => { + beforeEach(() => { + build({ rate: 3.25 }); + withMethod(PaymentMethodType.CBE_BILL, 'DJF'); // misconfigured row must not win + }); + + it('forces ETB regardless of the PaymentMethod row', async () => { + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CBE_BILL); + expect(quote).toMatchObject({ currency: 'ETB', amount: 1000 }); + }); + + it('passes the charge own 72h deadline as the intent expiry', async () => { + await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null); + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(sent.currency).toBe('ETB'); + expect(sent.expiresAt).toBe(charge.expiresAt.toISOString()); + // Comfortably longer than a browser-session TTL, so the sweep cannot kill the bill early. + expect(new Date(sent.expiresAt).getTime()).toBeGreaterThan( + Date.now() + 24 * 60 * 60 * 1000, + ); + }); + + it('sends the lead passenger as Full_Name, which CBE requires', async () => { + await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null); + expect(paymentClient.initiate.mock.calls[0][0].payerName).toBe( + 'Abebe Kebede', + ); + }); + + it('leaves the intent expiry unset for an open-ended charge', async () => { + charge.expiresAt = null; + await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null); + expect(paymentClient.initiate.mock.calls[0][0].expiresAt).toBeUndefined(); + }); + + it('reports a paid charge through getStatus without the payability gate', async () => { + prisma.supplementaryCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + paidAt: new Date(), + }); + await expect(service.getStatus(TOKEN)).resolves.toMatchObject({ + status: 'PAID', + paid: true, + }); + }); + }); +}); diff --git a/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx index fb2135cd3..da45893e5 100644 --- a/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; @@ -8,7 +8,10 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { PaymentMethod } from "@/types"; import { Loader2, + Check, + Copy, CreditCard, + KeyRound, Smartphone, Wallet, Landmark, @@ -23,6 +26,28 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; +// WALLET is an internal balance debit with no supplementary-charge path — the API refuses it, so +// it is never offered here. +const UNSUPPORTED_METHODS = ["WALLET"]; + +// Push-debit methods charge an account we must know before initiating: CAC Bank SMSes a one-time +// password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could +// collect the number afterwards, so it is asked for up front. +const requiresPayerMobile = (method: string | null) => + method === "CAC_BANK" || method === "EBIRR"; + +// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding, +// so the quote renders exactly the figure the provider will debit. +const formatAmount = (amount: number, currency: string) => + amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2); + +interface AmountQuote { + chargeId: string; + method: string; + currency: string; + amount: number; +} + export default function PayBalancePage() { const { token } = useParams<{ token: string }>(); const router = useRouter(); @@ -30,6 +55,26 @@ export default function PayBalancePage() { const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); + // Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC — + // the OTP the bank SMSes to it. + const [phoneModalOpen, setPhoneModalOpen] = useState(false); + const [payerMobile, setPayerMobile] = useState(""); + const [phoneError, setPhoneError] = useState(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpMessage, setOtpMessage] = useState(null); + const [otpError, setOtpError] = useState(null); + const [pushMessage, setPushMessage] = useState(null); + + // CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a + // branch/app later, so the page shows the number and watches for settlement. + const [billAction, setBillAction] = useState<{ + billReference: string; + instructions?: string; + expiresAt?: string; + } | null>(null); + const [billCopied, setBillCopied] = useState(false); + const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({ queryKey: ["supplementary-charge", token], queryFn: () => apiClient.get(`/payments/supplementary/by-token/${token}`), @@ -45,18 +90,102 @@ export default function PayBalancePage() { enabled: !!charge, }); + const availableMethods = useMemo( + () => + paymentMethods.filter( + (m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type), + ), + [paymentMethods], + ); + + // The charge is raised in ETB; this is what it costs before a method is chosen. + const chargeCurrency = charge?.currency ?? "ETB"; + const chargeAmount = useMemo( + () => Number(charge?.amountMinor ?? 0) / 100, + [charge], + ); + + // Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets + // in ETB), so the price has to be re-quoted server-side whenever the selection changes — the + // stored ETB amount is not what a Djiboutian wallet would debit. + const { + data: quote, + isFetching: fetchingQuote, + error: quoteError, + } = useQuery({ + queryKey: ["supplementaryAmount", token, selectedMethod], + queryFn: () => + apiClient.get( + `/payments/supplementary/by-token/${token}/amount?method=${selectedMethod}`, + ), + enabled: !!token && !!selectedMethod, + retry: false, + staleTime: 30_000, + }); + + // A quote is only usable once it belongs to the method currently selected — otherwise it is a + // leftover from the previous selection and would price the payment in the wrong currency. + const quoteReady = !fetchingQuote && quote?.method === selectedMethod; + const displayCurrency = selectedMethod ? (quote?.currency ?? "") : chargeCurrency; + const displayAmount = selectedMethod ? quote?.amount : chargeAmount; + const amountLabel = + quoteReady && displayAmount != null + ? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}` + : !selectedMethod && displayAmount != null + ? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}` + : null; + + // Never let Pay fire against a price the payer has not been shown. + const awaitingQuote = !!selectedMethod && !quoteReady; + const payMutation = useMutation({ - mutationFn: (method: string) => + mutationFn: (vars: { method: string; payerAccount?: string }) => apiClient.post(`/payments/supplementary/by-token/${token}/pay`, { - method, + method: vars.method, platform: "web", + ...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}), }), onSuccess: (data: any) => { - if (data?.clientAction?.type === "REDIRECT") { - window.location.href = resolvePaymentRedirectUrl(data.clientAction.url); + const action = data?.clientAction; + + if (action?.type === "REDIRECT") { + window.location.href = resolvePaymentRedirectUrl(action.url); return; } - // Immediate success (e.g. wallet) + + // CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm. + if (action?.type === "COLLECT_OTP") { + setOtpMessage(action.message ?? "Enter the OTP sent to your phone"); + setOtpCode(""); + setOtpError(null); + setOtpModalOpen(true); + setIsProcessing(false); + return; + } + + // CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number. + if (action?.type === "SHOW_BILL_REFERENCE") { + setBillAction({ + billReference: action.billReference, + instructions: action.instructions, + expiresAt: action.expiresAt, + }); + setBillCopied(false); + setIsProcessing(false); + return; + } + + // eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to. + if (action?.type === "AWAIT_PUSH") { + setPushMessage( + action.message ?? + `Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`, + ); + setIsProcessing(false); + return; + } + + // Immediate success router.push(`/pay-balance/${token}/success`); }, onError: (err: any) => { @@ -67,11 +196,90 @@ export default function PayBalancePage() { }, }); - const handlePay = () => { + // CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP — + // keep the modal open so the payer can re-enter it (the intent stays open). + const otpMutation = useMutation({ + mutationFn: (otp: string) => + apiClient.post(`/payments/supplementary/by-token/${token}/confirm`, { otp }), + onSuccess: () => { + setOtpModalOpen(false); + router.push(`/pay-balance/${token}/success`); + }, + onError: (err: any) => { + setOtpError( + err?.response?.data?.message ?? + err?.message ?? + "Invalid or expired OTP. Please try again.", + ); + }, + }); + + const startPayment = (mobile?: string) => { if (!selectedMethod) return; setIsProcessing(true); setPaymentError(null); - payMutation.mutate(selectedMethod); + payMutation.mutate({ + method: selectedMethod, + payerAccount: requiresPayerMobile(selectedMethod) ? mobile?.trim() : undefined, + }); + }; + + const handlePay = () => { + if (!selectedMethod || awaitingQuote) return; + setPaymentError(null); + + if (requiresPayerMobile(selectedMethod)) { + // Prefill with the number the charge was raised against, but leave it editable — the + // handset paying is often not the one the booking was made under. + if (!payerMobile.trim() && charge?.booking?.contactPhone) { + setPayerMobile(charge.booking.contactPhone); + } + setPhoneError(null); + setPhoneModalOpen(true); + return; + } + + startPayment(); + }; + + const submitPhone = () => { + if (!payerMobile.trim()) { + setPhoneError("Please enter your mobile number"); + return; + } + setPhoneModalOpen(false); + startPayment(payerMobile); + }; + + // While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens + // server-side — a CBE teller, or the provider's webhook — so the browser has no other signal. + // Success is only ever claimed from this, never from a client-side guess. + const watching = !!billAction || !!pushMessage; + const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({ + queryKey: ["supplementaryStatus", token], + queryFn: () => + apiClient.get<{ status: string; paid: boolean }>( + `/payments/supplementary/by-token/${token}/status`, + ), + enabled: !!token && watching, + refetchInterval: 5_000, + }); + + useEffect(() => { + if (watching && liveStatus?.paid) { + router.push(`/pay-balance/${token}/success`); + } + }, [watching, liveStatus?.paid, router, token]); + + const copyBillReference = async () => { + if (!billAction) return; + try { + await navigator.clipboard.writeText(billAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } catch { + /* clipboard unavailable — the number is still shown on screen */ + } }; if (loadingCharge) { @@ -95,8 +303,6 @@ export default function PayBalancePage() { ); } - const amountDisplay = (charge.amountMinor / 100).toFixed(2); - const currency = charge.currency ?? "ETB"; return (
@@ -122,8 +328,25 @@ export default function PayBalancePage() { )}
Amount due - {currency} {amountDisplay} + {amountLabel ? ( + {amountLabel} + ) : quoteError ? ( + + ) : ( + + )}
+ {selectedMethod && quoteReady && displayCurrency !== chargeCurrency && ( +

+ Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate +

+ )} + {quoteError && ( +

+ {(quoteError as any)?.response?.data?.message ?? + "This payment method is unavailable right now. Please choose another."} +

+ )}
{/* Payment methods */} @@ -136,7 +359,7 @@ export default function PayBalancePage() {
) : (
- {paymentMethods.filter((m) => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( @@ -173,19 +396,180 @@ export default function PayBalancePage() {

🔒 Secure & encrypted payment

+ + {/* CBE bill — show the number; confirmation only ever comes from the status poll */} + {billAction && ( +
+
+
+ +

Pay at CBE

+
+

+ {billAction.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} +

+
+ + {billAction.billReference} + + +
+
+

+ Amount: ETB {formatAmount(chargeAmount, "ETB")} +

+ {billAction.expiresAt && ( +

+ Pay before:{" "} + + {new Date(billAction.expiresAt).toLocaleString()} + +

+ )} +
+
+ + Waiting for payment confirmation — this page updates automatically once CBE + confirms your payment. +
+ +
+
+ )} + + {/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */} + {pushMessage && ( +
+ +
+

Check your phone

+

{pushMessage}

+
+
+ )} + + {/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */} + {phoneModalOpen && ( +
+
+
+ +

Your mobile number

+
+

+ {selectedMethod === "EBIRR" + ? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you." + : "CAC Bank will send a one-time password to this number to authorize the payment."} +

+ { setPayerMobile(e.target.value); setPhoneError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }} + placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"} + className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {phoneError && ( +

⚠️ {phoneError}

+ )} +
+ + +
+
+
+ )} + + {/* CAC Bank OTP entry */} + {otpModalOpen && ( +
+
+
+ +

Enter OTP

+
+

{otpMessage}

+ { setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }} + onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }} + placeholder="Enter code" + maxLength={10} + className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {otpError && ( +

⚠️ {otpError}

+ )} +
+ + +
+
+
+ )}
); From c723b660e254f5a13eca109872a46111b83e2d38 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 18 Aug 2026 08:27:18 +0000 Subject: [PATCH 08/60] feat(freight): offer built-train wagons per boarding yard on multi-yard consists --- apps/edr-freight-api/src/app.module.ts | 2 + .../3560000000000-ManualPaymentSettings.ts | 36 +++ .../modules/billing/billing.service.spec.ts | 11 + .../src/modules/billing/billing.service.ts | 30 +- .../dto/update-manual-payment-setting.dto.ts | 18 ++ .../entities/manual-payment-setting.entity.ts | 26 ++ .../manual-payment-settings.controller.ts | 47 +++ .../manual-payment-settings.service.ts | 71 +++++ .../payment-settings.module.ts | 20 ++ .../train-scheduling/booking-batch.service.ts | 16 +- .../services/train-scheduling.service.ts | 109 ++++++- .../train-scheduling/wagon-plan-flex.util.ts | 10 + .../wagon-stock-ledger.util.spec.ts | 91 ++++++ .../wagon-stock-ledger.util.ts | 45 ++- .../src/modules/trains/dto/build-train.dto.ts | 2 +- .../modules/trains/train-builder.service.ts | 58 +++- .../src/modules/wagons/wagons.service.ts | 9 +- .../src/seed/freight-permissions.registry.ts | 21 ++ apps/edr-freight-web/backoffice/src/App.tsx | 13 + .../components/layout/sidebar-sections.tsx | 5 + .../trainBuilder/AvailableWagonsPanel.tsx | 162 +++++++---- .../trainBuilder/ConsistWagonList.tsx | 7 +- .../backoffice/src/constants/URLS.ts | 4 + .../src/hooks/useManualPaymentSettings.ts | 39 +++ .../backoffice/src/lib/permissions.ts | 6 + .../src/pages/invoices/FinanceHubPage.tsx | 40 ++- .../src/pages/invoices/UsdPaymentsPage.tsx | 66 ++--- .../settings/ManualPaymentSettingsCard.tsx | 132 +++++++++ .../trainBuilder/TrainBuilderDetailPage.tsx | 31 +- .../services/manualPaymentSettings.service.ts | 36 +++ .../src/services/trainBuilder.service.ts | 11 + .../portal/src/hooks/useAuth.ts | 19 +- .../src/pages/contracts/ContractViewPage.tsx | 272 +++++++++++++----- 33 files changed, 1234 insertions(+), 231 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d38c6ea92..d0ab805a8 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -50,6 +50,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; @@ -213,6 +214,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + PaymentSettingsModule, StampSettingsModule, LogoSettingsModule, ContractTemplatesModule, diff --git a/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts new file mode 100644 index 000000000..0c4f5698f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table controlling whether Finance may settle invoices by hand, + * per currency (see ManualPaymentSettingsService). Defaults preserve the + * pre-toggle behaviour: USD was always bank-transfer-only (ON), ETB manual + * settlement is the new capability and must be switched on deliberately (OFF). + */ +export class ManualPaymentSettings3560000000000 implements MigrationInterface { + name = "ManualPaymentSettings3560000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.manual_payment_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + etb_enabled boolean NOT NULL DEFAULT false, + usd_enabled boolean NOT NULL DEFAULT true, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + INSERT INTO freight.manual_payment_settings (etb_enabled, usd_enabled) + SELECT false, true + WHERE NOT EXISTS (SELECT 1 FROM freight.manual_payment_settings); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.manual_payment_settings;`, + ); + } +} 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 243b1ee4d..c5cc9a81e 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 @@ -81,6 +81,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); }); @@ -163,6 +164,7 @@ describe("BillingService.issueMemo", () => { {} as never, {} as never, { get: () => undefined } as never, + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, manager, savedLines }; } @@ -297,6 +299,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -352,6 +355,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -397,6 +401,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, mg, events }; } @@ -510,6 +515,7 @@ describe("BillingService.recordPayment", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, mg, events }; } @@ -627,6 +633,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, defaultManager, txManager, transaction }; }; @@ -700,6 +707,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, manager }; }; @@ -791,6 +799,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, repo }; }; @@ -874,6 +883,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, repo }; }; @@ -943,6 +953,7 @@ describe("BillingService.document", () => { ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } : undefined, } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, render, renderThermal }; }; 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 44e194a8d..d64361bf8 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -17,6 +17,7 @@ import { Booking } from "../bookings/entities/booking.entity"; // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity"; +import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { EimsInvoiceStatus } from "../eims/eims-registration.types"; @@ -201,6 +202,7 @@ export class BillingService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly files: FilesService, private readonly config: ConfigService, + private readonly manualPaymentSettings: ManualPaymentSettingsService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -370,20 +372,24 @@ export class BillingService { const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; + // Only currencies whose manual-payment channel is switched on are listed: + // a row Finance cannot act on is noise, and the confirm endpoint would + // refuse it anyway. All off → nothing to work. + const enabled = await this.manualPaymentSettings.enabledCurrencies(); + if (!enabled.length) return { items: [], total: 0 }; + const currencies = filter.currency + ? enabled.filter((c) => c === filter.currency) + : enabled; + if (!currencies.length) return { items: [], total: 0 }; + const qb = this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") - .where("UPPER(invoice.currency) IN ('USD', 'ETB')") + .where("UPPER(invoice.currency) IN (:...currencies)", { currencies }) .orderBy("invoice.issuedAt", "DESC") .skip((page - 1) * pageSize) .take(pageSize); - - if (filter.currency) { - qb.andWhere("UPPER(invoice.currency) = :currency", { - currency: filter.currency, - }); - } if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } else { @@ -465,7 +471,8 @@ export class BillingService { /** * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer - * or counter payment: stores the slip against the invoice and settles the + * or counter payment. Refused when that currency's manual-payment channel is + * switched off in settings. Stores the slip against the invoice and settles the * FULL outstanding balance through * {@link recordPayment}, which flips the invoice to PAID and (for bookings) * emits `booking.invoice.paid` — the same event an online payment fires, so @@ -485,6 +492,13 @@ export class BillingService { ): Promise { const invoice = await this.invoices.findById(invoiceId); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + // The channel is a setting, not a role: even a permitted user cannot + // settle by hand in a currency whose channel is switched off. + if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) { + throw new BadRequestException( + `Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`, + ); + } if (!file) { throw new BadRequestException("The bank payment slip file is required."); } diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts new file mode 100644 index 000000000..971de03cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional } from "class-validator"; + +/** + * Partial update: the UI flips one currency at a time, so an omitted field + * leaves that currency's channel exactly as it was. + */ +export class UpdateManualPaymentSettingDto { + @ApiPropertyOptional({ description: "Allow manual settlement of ETB invoices" }) + @IsOptional() + @IsBoolean() + etbEnabled?: boolean; + + @ApiPropertyOptional({ description: "Allow manual settlement of USD invoices" }) + @IsOptional() + @IsBoolean() + usdEnabled?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts new file mode 100644 index 000000000..a18f97279 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +/** + * Single-row table controlling whether Finance may settle invoices by hand + * (bank transfer / counter payment) instead of the customer paying online. + * + * Per currency on purpose: the two channels are operationally different — USD + * bookings have always been bank-transfer-only, while ETB normally goes + * through the gateway and manual settlement is the exception. Switching one + * off must not switch off the other. + */ +@Entity({ schema: "freight", name: "manual_payment_settings" }) +export class ManualPaymentSetting extends BaseEntity { + /** Manual settlement allowed for ETB invoices. */ + @Column({ name: "etb_enabled", type: "boolean", default: false }) + etbEnabled!: boolean; + + /** Manual settlement allowed for USD invoices. */ + @Column({ name: "usd_enabled", type: "boolean", default: true }) + usdEnabled!: boolean; + + /** IAM user id of the last operator to change either toggle. */ + @Column({ name: "updated_by_id", type: "uuid", nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts new file mode 100644 index 000000000..786d1f7ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Get, Patch } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { UpdateManualPaymentSettingDto } from "./dto/update-manual-payment-setting.dto"; +import { ManualPaymentSettingsService } from "./manual-payment-settings.service"; + +@ApiTags("payment-settings") +@ApiBearerAuth() +@Controller("payment-settings/manual") +export class ManualPaymentSettingsController { + constructor(private readonly service: ManualPaymentSettingsService) {} + + /** + * Read is gated on `manual_payment:view`, which Finance also holds — the + * Manual Payments worklist reads this to know which currency tabs to offer. + */ + @Get() + @BookingStaff([ + FREIGHT_PERMS.settings.manualPayment.view, + FREIGHT_PERMS.admin, + ]) + @ApiOperation({ + summary: "Whether manual (offline) invoice settlement is enabled, per currency", + }) + get() { + return this.service.get(); + } + + @Patch() + @BookingStaff([ + FREIGHT_PERMS.settings.manualPayment.manage, + FREIGHT_PERMS.admin, + ]) + @ApiOperation({ + summary: "Enable or disable manual invoice settlement for ETB and/or USD", + }) + update( + @Body() dto: UpdateManualPaymentSettingDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.update(dto, user?.id ?? null); + } +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts new file mode 100644 index 000000000..efb43fa91 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; + +/** The two currencies an invoice can be settled by hand in. */ +export type ManualPaymentCurrency = "ETB" | "USD"; + +/** + * Owns the single `manual_payment_settings` row: whether Finance may settle + * invoices by hand, per currency. + * + * Defaults mirror how the platform behaved before the toggles existed — USD + * has always been bank-transfer-only so it starts ON; ETB manual settlement is + * the new capability and starts OFF, so enabling it is a deliberate act. + */ +@Injectable() +export class ManualPaymentSettingsService { + private readonly logger = new Logger(ManualPaymentSettingsService.name); + + constructor( + @InjectRepository(ManualPaymentSetting) + private readonly repository: Repository, + ) {} + + /** The settings row, created at the defaults on first access. */ + async get(): Promise { + const existing = await this.repository.findOne({ where: {} }); + if (existing) return existing; + + return this.repository.save( + this.repository.create({ etbEnabled: false, usdEnabled: true }), + ); + } + + /** Currencies manual settlement is currently allowed for. */ + async enabledCurrencies(): Promise { + const setting = await this.get(); + const enabled: ManualPaymentCurrency[] = []; + if (setting.etbEnabled) enabled.push("ETB"); + if (setting.usdEnabled) enabled.push("USD"); + return enabled; + } + + /** Whether one currency may be settled by hand right now. */ + async isEnabled(currency: string | null | undefined): Promise { + const upper = currency?.toUpperCase(); + if (upper !== "ETB" && upper !== "USD") return false; + const setting = await this.get(); + return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled; + } + + /** Flip either toggle; an omitted field leaves that currency unchanged. */ + async update( + patch: { etbEnabled?: boolean; usdEnabled?: boolean }, + updatedById?: string | null, + ): Promise { + const current = await this.get(); + await this.repository.update(current.id, { + ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), + ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), + updatedById: updatedById ?? null, + }); + const updated = await this.get(); + this.logger.warn( + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`, + ); + return updated; + } +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts b/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts new file mode 100644 index 000000000..bcadfe340 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts @@ -0,0 +1,20 @@ +import { Global, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; +import { ManualPaymentSettingsController } from "./manual-payment-settings.controller"; +import { ManualPaymentSettingsService } from "./manual-payment-settings.service"; + +/** + * Global so billing can inject {@link ManualPaymentSettingsService} to gate + * the manual-settlement worklist and confirmation endpoint without importing + * this module (and without a cycle, since this module needs nothing back). + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([ManualPaymentSetting])], + controllers: [ManualPaymentSettingsController], + providers: [ManualPaymentSettingsService], + exports: [ManualPaymentSettingsService], +}) +export class PaymentSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 4418eed3d..cb1958582 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1221,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit { const ledger = new WagonStockLedger( stock.remainingByTypeId, Math.max(1, budget.stops.length - 1), + stock.byYardId, + budget.stops, ); + // On a multi-yard consist the pool that matters is the one standing at + // the booking's own boarding yard — a type carried only in Mojo must not + // be advertised to a customer boarding at Dire. + const carriedAtBoardYard = (wagonTypeId: string): number => { + const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null; + if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0; + return stock.remainingByTypeId.get(wagonTypeId) ?? 0; + }; const byWagonType = allowed .filter( ({ wagonTypeId }) => - stock.mode !== 'TRAIN' || - !wagonTypeId || - (stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0, + stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0, ) .map(({ wagonTypeId, dims }) => { const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; @@ -4783,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit { return new WagonStockLedger( stock.remainingByTypeId, Math.max(1, budget.stops.length - 1), + stock.byYardId, + budget.stops, ); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 03535135d..9a20c0486 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -1589,6 +1589,7 @@ export class TrainSchedulingService { `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`, ); } + await this.assertRouteCoversWagonYards(builtTrain, route); const conflict = await this.findTrainRouteDayConflict( builtTrain.id, route.id, @@ -4274,12 +4275,26 @@ export class TrainSchedulingService { .getRepository(Locomotive) .update({ id: In(locoIds) }, { currentYardId: station.yardId }); } + // Only wagons the train has actually COLLECTED move with it. On a + // consist spread across yards (20 in Dire, 33 in Mojo), reaching Mojo + // moves the Dire wagons — the ones already aboard — and picks up the + // Mojo ones standing here. Wagons waiting at yards further down the + // line stay where they are until the train physically gets to them. + const passedYardIds = stations + .filter((s) => s.sequenceNo <= dto.sequenceNo) + .map((s) => s.yardId); await manager .getRepository(Wagon) - .update( - { currentTrainScheduleId: scheduleId }, - { currentYardId: station.yardId }, - ); + .createQueryBuilder() + .update(Wagon) + .set({ currentYardId: station.yardId }) + .where('current_train_schedule_id = :scheduleId', { scheduleId }) + // A yard-less wagon has no "waiting further down the line" position + // to protect, so it rides along as it always did. + .andWhere('(current_yard_id IS NULL OR current_yard_id IN (:...passedYardIds))', { + passedYardIds, + }) + .execute(); if (schedule.trainSet?.trainId) { await manager .getRepository(Train) @@ -5286,12 +5301,26 @@ export class TrainSchedulingService { const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); + // A built consist spread across several yards can only offer, at each yard, + // the wagons standing there. A single-yard consist keeps the original + // behaviour: the whole train counts wherever it currently sits. + const consistYards = builtTrainId + ? new Set( + wagons + .filter((w) => w.trainId === builtTrainId && w.currentYardId) + .map((w) => w.currentYardId as string), + ) + : new Set(); + const consistIsSplit = consistYards.size > 1; + for (const wagon of wagons) { // Train-bound schedule: the built train's own consist IS the fleet — only - // its wagons count (wherever they currently sit; they travel with the - // train), and loose yard wagons never do. + // its wagons count, and loose yard wagons never do. A single-yard consist + // counts wherever it sits (it travels with the train); a split consist is + // counted at the yard each wagon actually stands in. if (builtTrainId) { if (wagon.trainId !== builtTrainId) continue; + if (consistIsSplit && wagon.currentYardId !== originYardId) continue; } else { // Schedule-scoped availability: pins held by OTHER schedules never // consume a wagon here — the same physical wagon may serve the July 17 @@ -5651,12 +5680,23 @@ export class TrainSchedulingService { // consist views draw the schedule exactly like the train builder; a schedule // created with reverseWagonOrder pins back-to-front (physically-last wagon // takes slot #1). Unsequenced wagons sort after every sequenced one. + const consistYards = new Set( + wagons + .filter((w) => w.trainId === builtTrainId && w.currentYardId) + .map((w) => w.currentYardId as string), + ); + // Split consist: a slot boarding at a given yard must take a wagon that + // physically stands there — the train cannot load a Mojo wagon at Dire. + // A single-yard consist ignores this (the whole train is at one place). + const requiredYardId = + consistYards.size > 1 ? (slot.boardYardId ?? originYardId) : null; const candidates = wagons .filter( (w) => w.trainId === builtTrainId && w.wagonTypeId === slot.wagonTypeId && - spanFree(w.id), + spanFree(w.id) && + (!requiredYardId || w.currentYardId === requiredYardId), ) .sort((a, b) => { if (a.sequenceNumber == null || b.sequenceNumber == null) { @@ -5825,14 +5865,28 @@ export class TrainSchedulingService { }); const remainingByTypeId = new Map(); const codesByTypeId = new Map(); + const byYardId = new Map>(); for (const wagon of wagons) { remainingByTypeId.set( wagon.wagonTypeId, (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, ); if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); + if (wagon.currentYardId) { + const perType = byYardId.get(wagon.currentYardId) ?? new Map(); + perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1); + byYardId.set(wagon.currentYardId, perType); + } } - return { mode: 'TRAIN', remainingByTypeId, codesByTypeId }; + // Single-yard consist (the overwhelming majority): the whole train is + // offered at every boarding yard exactly as before — the per-yard split is + // only meaningful once the consist is genuinely spread across yards. + return { + mode: 'TRAIN', + remainingByTypeId, + codesByTypeId, + ...(byYardId.size > 1 ? { byYardId } : {}), + }; } /** @@ -6160,6 +6214,45 @@ export class TrainSchedulingService { return saved; } + /** + * A built train's wagons may stand in several yards. The route must pass + * through every one of them as origin or an intermediate stop — never only + * as the final destination (the train has to pick the wagons up en route). + */ + private async assertRouteCoversWagonYards(train: Train, route: Route) { + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: { trainId: train.id }, + select: { id: true, currentYardId: true }, + }); + const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))]; + if (!wagonYards.length) return; + + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: route.id }, order: { sequenceNo: 'ASC' } }); + const stops = milestones.length >= 2 + ? milestones.map((m) => m.yardId) + : [route.originYardId, route.destinationYardId]; + // Every stop except the last one is a pickup point. + const pickupYards = new Set(stops.slice(0, -1)); + + const uncovered = wagonYards.filter((y) => !pickupYards.has(y)); + if (!uncovered.length) return; + + const labels = await this.yardLabelMap(uncovered); + const destination = stops[stops.length - 1]; + const detail = uncovered + .map((y) => + y === destination + ? `${labels.get(y) ?? y} (only as the destination)` + : `${labels.get(y) ?? y} (not on route)`, + ) + .join(', '); + throw new BadRequestException( + `Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`, + ); + } + private async getSchedulableRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, 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 f7db22506..29a67da45 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 @@ -43,6 +43,16 @@ export type WagonStock = { remainingByTypeId: Map; /** Wagon-type code per id, for human-readable shortfall messages. */ codesByTypeId: Map; + /** + * Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons + * standing at that yard. A train whose wagons are split across yards can + * only offer, at each boarding yard, the wagons physically standing there — + * a wagon waiting in Mojo is not bookable from Dire, and one picked up at + * Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits + * in one yard, which keeps single-yard trains on the original whole-train + * math. + */ + byYardId?: Map>; }; export type FlexPlanResult = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts index 47823cddd..5815ce435 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts @@ -68,3 +68,94 @@ describe('WagonStockLedger', () => { expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10); }); }); + +describe('WagonStockLedger — multi-yard consist', () => { + // The reported case: a built train of 53 wagons, 20 standing in Dire and 33 + // in Mojo. Each yard may only sell the wagons physically standing there. + const DIRE = 'yard-dire'; + const MOJO = 'yard-mojo'; + const ADDIS = 'yard-addis'; + const STOPS = [DIRE, MOJO, ADDIS]; + const EDGES = STOPS.length - 1; + const splitStock = () => + new Map([ + [DIRE, new Map([['nw5', 20]])], + [MOJO, new Map([['nw5', 33]])], + ]); + // Legs along Dire → Mojo → Addis. + const DIRE_TO_ADDIS = { fromEdge: 0, toEdge: 2 }; + const MOJO_TO_ADDIS = { fromEdge: 1, toEdge: 2 }; + + const splitLedger = () => + new WagonStockLedger(new Map([['nw5', 53]]), EDGES, splitStock(), STOPS); + + it('offers each yard only the wagons standing there', () => { + const ledger = splitLedger(); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(20); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + }); + + it('keeps the yards independent — Dire bookings never eat Mojo stock', () => { + const ledger = splitLedger(); + // A Dire booking rides the whole corridor, occupying the Mojo→Addis edge… + expect(ledger.consume(['nw5'], 20, DIRE_TO_ADDIS)).toBe(20); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(0); + // …but those are Dire's steel, so Mojo still has its own 33 to sell. + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + expect(ledger.consume(['nw5'], 33, MOJO_TO_ADDIS)).toBe(33); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(0); + }); + + it('never lends a free Dire wagon to a Mojo customer', () => { + const ledger = splitLedger(); + // Only 5 of Dire's 20 sell; the other 15 ride past Mojo empty. + expect(ledger.consume(['nw5'], 5, DIRE_TO_ADDIS)).toBe(5); + // Mojo is still capped at its own 33 — the 15 empty Dire wagons are not + // offered here, exactly as the operator requires. + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + expect(ledger.consume(['nw5'], 40, MOJO_TO_ADDIS)).toBe(33); + }); + + it('offers nothing at the destination — there is nothing to pick up there', () => { + const ledger = splitLedger(); + // A leg boarding at the last stop has no pool of its own. + expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 2 })).toBe(0); + }); + + it('second example: Addis → Dire → Indode → Mojo → Djibouti', () => { + const [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI] = [ + 'yard-add', + 'yard-dire', + 'yard-indode', + 'yard-mojo', + 'yard-djibouti', + ]; + const stops = [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI]; + const ledger = new WagonStockLedger( + new Map([['nw5', 53]]), + stops.length - 1, + new Map([ + [DIRE_2, new Map([['nw5', 20]])], + [MOJO_2, new Map([['nw5', 33]])], + ]), + stops, + ); + const to = (fromEdge: number) => ({ fromEdge, toEdge: stops.length - 1 }); + // Addis: the train starts empty — nothing to sell. + expect(ledger.availableFor(['nw5'], to(0))).toBe(0); + // Dire: the 20 wagons waiting there. + expect(ledger.availableFor(['nw5'], to(1))).toBe(20); + // Indode: the same 20 wagons, which have moved with the train. + expect(ledger.availableFor(['nw5'], to(2))).toBe(0); + // Mojo: its own 33 only. + expect(ledger.availableFor(['nw5'], to(3))).toBe(33); + }); + + it('single-yard consist keeps the original whole-train behaviour', () => { + // No byYardId (the train is not split) — every leg sees the whole train, + // exactly as before this feature. + const ledger = new WagonStockLedger(new Map([['nw5', 53]]), EDGES); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(53); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts index 0e4f6949d..bf5b935ad 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts @@ -20,17 +20,53 @@ import type { CorridorLeg } from './corridor-capacity.util'; * Gelan→Adama never competes for stock with an export on Adama→Doraleh. */ export class WagonStockLedger { + /** + * Usage rows keyed by pool. A single-yard train has one pool (''), so this is + * exactly the original per-type accounting. A multi-yard consist keys by + * boarding yard as well, because the Dire wagons and the Mojo wagons are + * disjoint sets of steel: 5 Dire wagons riding the whole corridor occupy the + * Mojo→Addis edge, but they must not shrink what Mojo itself can offer. + */ private readonly usedPerEdge = new Map(); constructor( private readonly remainingByTypeId: Map, private readonly edgeCount: number, + /** + * Multi-yard consist only (see {@link WagonStock.byYardId}): the wagons + * standing at each yard. When present, a leg is served ONLY by the wagons + * standing at the yard it boards from — a Dire→Addis booking on a train + * whose wagons sit 20 in Dire and 33 in Mojo sees 20, and a Mojo→Addis + * booking sees 33, never the Dire wagons that ride past empty. + */ + private readonly byYardId?: Map>, + /** Ordered corridor stops, parallel to the edges — maps an edge to its yard. */ + private readonly stops: readonly string[] = [], ) {} + /** The yard a leg boards from, or '' when the train is not split across yards. */ + private poolYardOf(leg: CorridorLeg): string { + if (!this.byYardId) return ''; + return this.stops[leg.fromEdge] ?? ''; + } + + /** Usage-row key: one row per (pool, wagon type). */ + private rowKey(wagonTypeId: string, leg: CorridorLeg): string { + const pool = this.poolYardOf(leg); + return pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId; + } + + /** Wagons of one type offered at the yard a leg boards from. */ + private totalForType(wagonTypeId: string, leg: CorridorLeg): number { + const pool = this.poolYardOf(leg); + if (!pool) return this.remainingByTypeId.get(wagonTypeId) ?? 0; + return this.byYardId?.get(pool)?.get(wagonTypeId) ?? 0; + } + /** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */ private availableForType(wagonTypeId: string, leg: CorridorLeg): number { - const total = this.remainingByTypeId.get(wagonTypeId) ?? 0; - const row = this.usedPerEdge.get(wagonTypeId); + const total = this.totalForType(wagonTypeId, leg); + const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg)); if (!row) return total; let busiest = 0; for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { @@ -69,10 +105,11 @@ export class WagonStockLedger { if (!deepest) break; const take = Math.min(outstanding, deepest.free); - let row = this.usedPerEdge.get(deepest.id); + const key = this.rowKey(deepest.id, leg); + let row = this.usedPerEdge.get(key); if (!row) { row = new Array(this.edgeCount).fill(0); - this.usedPerEdge.set(deepest.id, row); + this.usedPerEdge.set(key, row); } for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { row[edge] = (row[edge] ?? 0) + take; diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 7b0bdd3bd..08a424cdc 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -44,7 +44,7 @@ export class BuildTrainDto { @ApiPropertyOptional({ type: [String], format: 'uuid', - description: 'Wagons to attach at build time, in consist order (must sit in the same yard)', + description: 'Wagons to attach at build time, in consist order (any yard)', }) @IsOptional() @IsArray() diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 0f377071c..4b94b2dcb 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -283,6 +283,10 @@ export class TrainBuilderService { wagonNumber: wagon.wagonNumber, sequenceNumber: wagon.sequenceNumber, status: wagon.status, + currentYardId: wagon.currentYardId ?? null, + currentYard: wagon.currentYard + ? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label } + : null, wagonType: wagon.wagonType ? { id: wagon.wagonType.id, @@ -326,6 +330,27 @@ export class TrainBuilderService { : null, locomotives, wagons, + // Where the consist physically stands. A train built from several yards + // only picks a yard's wagons up when it reaches that yard, and a customer + // boarding there can only book the wagons standing there — the schedule + // route must therefore cover every one of these yards before its + // destination. + wagonYards: [ + ...wagons + .reduce((acc, wagon) => { + const id = wagon.currentYardId ?? 'UNASSIGNED'; + const entry = acc.get(id) ?? { + yardId: wagon.currentYardId ?? null, + code: wagon.currentYard?.code ?? null, + label: wagon.currentYard?.label ?? null, + wagonCount: 0, + }; + entry.wagonCount += 1; + acc.set(id, entry); + return acc; + }, new Map()) + .values(), + ].sort((a, b) => b.wagonCount - a.wagonCount), totals: { wagonCount: wagons.length, totalTareTons, @@ -428,10 +453,13 @@ export class TrainBuilderService { } /** - * Relocate the train to another yard. The consist moves as one unit: every - * coupled locomotive and wagon follows to the new yard (so their current - * yards always match the train's), and each wagon gets a movement-ledger row. - * Blocked while the train is out on a dispatched run. + * Relocate the train to another yard. The locomotives always follow. Of the + * wagons, only those standing WITH the train move: on a consist spread + * across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo + * relocates the 20 it is actually pulling and leaves the Mojo wagons where + * they stand — the train collects those by arriving, not by this call. + * Each moved wagon gets a movement-ledger row. Blocked while the train is + * out on a dispatched run. */ async setYard(id: string, currentYardId: string) { await this.dataSource.transaction(async (manager) => { @@ -439,6 +467,7 @@ export class TrainBuilderService { if (train.currentYardId === currentYardId) return; const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); + const previousYardId = train.currentYardId ?? null; await manager.getRepository(Train).update(train.id, { currentYardId: yard.id }); @@ -454,7 +483,17 @@ export class TrainBuilderService { ); } - const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } }); + const allWagons = await manager + .getRepository(Wagon) + .find({ where: { trainId: train.id } }); + // Wagons travelling with the train = those at the yard it is leaving. + // A yard-less wagon has no standing position of its own, so it follows. + const wagons = allWagons.filter( + (wagon) => + wagon.currentYardId == null || + previousYardId == null || + wagon.currentYardId === previousYardId, + ); const now = new Date(); for (const wagon of wagons) { if (wagon.currentYardId === yard.id) continue; @@ -475,7 +514,7 @@ export class TrainBuilderService { return this.getComposition(id); } - /** Append AVAILABLE wagons from the train's own yard to the consist. */ + /** Append AVAILABLE, unassigned wagons (any yard) to the consist. */ async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); @@ -1037,11 +1076,8 @@ export class TrainBuilderService { if (wagon.status !== WagonStatus.Available) { throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`); } - if (wagon.currentYardId !== train.currentYardId) { - throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, - ); - } + // Wagons may sit in any yard — the schedule's route must pass through + // every wagon yard before its destination (checked at scheduling time). toAttach.push(wagon); } if (!toAttach.length) return []; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index b5a14f6f3..f2c76e283 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -342,8 +342,8 @@ export class WagonsService { async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { const wagon = await this.findById(wagonId); - // Mirror train-builder attachWagons: only a truly free, available wagon in - // the train's own yard can be coupled, and never onto a dispatched train. + // Mirror train-builder attachWagons: only a truly free, available wagon + // (any yard) can be coupled, and never onto a dispatched train. if (wagon.trainId != null) { throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`); } @@ -358,11 +358,6 @@ export class WagonsService { `Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`, ); } - if (wagon.currentYardId !== train.currentYardId) { - throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, - ); - } const maxSeq = await this.wagonRepo .createQueryBuilder('w') diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 24be04f66..87ba8cf8a 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1465,6 +1465,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:exchange_rate:manage", "Set the USD-ETB fallback rate", ), + perm( + "b4d00001-0001-4000-8000-000000000003", + "edr_freight_app:settings:manual_payment:view", + "View the manual (offline) payment channel settings", + ), + perm( + "b4d00001-0001-4000-8000-000000000004", + "edr_freight_app:settings:manual_payment:manage", + "Enable or disable manual invoice settlement per currency", + ), perm( "b4e00001-0001-4000-8000-000000000001", "edr_freight_app:settings:contract_templates:view", @@ -2112,6 +2122,14 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", }, + // Whether Finance may settle invoices by hand, per currency. Split + // view/manage on purpose: Finance reads it (the worklist offers only the + // enabled currencies) but must not switch its own channel on — same + // maker-checker split as the other sensitive finance settings. + manualPayment: { + view: "edr_freight_app:settings:manual_payment:view", + manage: "edr_freight_app:settings:manual_payment:manage", + }, contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", @@ -2416,6 +2434,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.invoices.export, // Manual settlement (bank transfer / counter) of USD and ETB invoices. FREIGHT_PERMS.invoices.confirmOffline, + // Read-only: the worklist offers whichever currencies are switched on. + // Flipping the switch is deliberately NOT here — see `manualPayment`. + FREIGHT_PERMS.settings.manualPayment.view, // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, // eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all // (the cron sweep runs as the system); these are the *manual* exceptional-operations diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index c131af4bd..f07ec32b5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -86,6 +86,7 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; +import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -1147,6 +1148,18 @@ const App = () => { } /> + +
+ +
+ + } + /> ("ALL"); + const [yardFilter, setYardFilter] = useState("ALL"); const [runOnly, setRunOnly] = useState(false); const [selected, setSelected] = useState([]); + const [page, setPage] = useState(1); // The train's own run, e.g. "8001-8002" — only offered when the train has one. const runLabel = exportTrainNumber @@ -39,59 +45,65 @@ export default function AvailableWagonsPanel({ : null; const wagonsQuery = useQuery( - api.wagons.list.queryOptions({ + api.wagons.listPaged.queryOptions({ input: { filters: { status: Freight.WagonStatus.Available, - currentYardId: yardId, // Loose wagons only — one already on another train cannot be coupled. unassigned: true, + search: debouncedSearch.trim() || undefined, + currentYardId: yardFilter === "ALL" ? undefined : yardFilter, + wagonTypeId: typeFilter === "ALL" ? undefined : typeFilter, + // Rostered to this train's run — the API matches either run column. + trainNumber: runOnly && exportTrainNumber ? exportTrainNumber : undefined, + page, + pageSize: PAGE_SIZE, }, }, - enabled: Boolean(yardId), }), ); - const wagons = useMemo(() => { - const q = search.trim().toLowerCase(); - return (wagonsQuery.data ?? []).filter((wagon) => { - if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false; - // Rostered to this train's run — match on the export run, which fixes the - // import run anyway. - if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false; - if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false; - return true; - }); - }, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]); + const wagons = wagonsQuery.data?.items ?? []; + const total = wagonsQuery.data?.meta.total ?? 0; + const totalPages = Math.max(1, wagonsQuery.data?.meta.totalPages ?? 1); - const runMatchCount = useMemo( - () => - exportTrainNumber - ? (wagonsQuery.data ?? []).filter( - (w) => w.exportTrainNumber === exportTrainNumber, - ).length - : 0, - [wagonsQuery.data, exportTrainNumber], - ); + // Filters change → back to page 1 (and clamp when the list shrinks). + useEffect(() => { + setPage(1); + }, [debouncedSearch, typeFilter, yardFilter, runOnly]); + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); - const typeOptions = useMemo(() => { - const byId = new Map(); - for (const wagon of wagonsQuery.data ?? []) { - if (wagon.wagonType) { - // e.g. "Flat wagon (NW5)" — name with its type code. - byId.set( - wagon.wagonType.id, - wagon.wagonType.code - ? `${wagon.wagonType.name} (${wagon.wagonType.code})` - : wagon.wagonType.name, - ); - } - } + // Dropdowns come from the reference lists, not the current page — a yard or + // type must stay pickable even when this page holds none of it. + const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 })); + const wagonTypesQuery = useQuery(api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000 })); + + const yardOptions = useMemo(() => { + const yards = [...(yardsQuery.data ?? [])].sort((a, b) => + a.id === homeYardId ? -1 : b.id === homeYardId ? 1 : a.label.localeCompare(b.label), + ); return [ - { value: "ALL", label: "All types" }, - ...[...byId.entries()].map(([value, label]) => ({ value, label })), + { value: "ALL", label: "All yards" }, + ...yards.map((yard) => ({ + value: yard.id, + label: `${yard.label}${yard.id === homeYardId ? " · train's yard" : ""}`, + })), ]; - }, [wagonsQuery.data]); + }, [yardsQuery.data, homeYardId]); + + const typeOptions = useMemo( + () => [ + { value: "ALL", label: "All types" }, + // e.g. "Flat wagon (NW5)" — name with its type code. + ...(wagonTypesQuery.data ?? []).map((type) => ({ + value: type.id, + label: type.code ? `${type.name} (${type.code})` : type.name, + })), + ], + [wagonTypesQuery.data], + ); const toggle = (wagonId: string, checked: boolean) => { setSelected((prev) => @@ -99,8 +111,8 @@ export default function AvailableWagonsPanel({ ); }; - const allSelected = - wagons.length > 0 && wagons.every((w) => selected.includes(w.id)); + // Select-all covers this page only — the rest of the matches are not loaded. + const allSelected = wagons.length > 0 && wagons.every((w) => selected.includes(w.id)); const someSelected = wagons.some((w) => selected.includes(w.id)); const toggleAll = (checked: boolean) => { @@ -138,24 +150,40 @@ export default function AvailableWagonsPanel({ onChange={(v) => setTypeFilter(v ?? "ALL")} /> + { setEmail(e.target.value); setError(''); }} - onFocus={() => setEmailFocused(true)} - onBlur={() => setEmailFocused(false)} + type="text" + value={identifier} + onChange={(e) => { setIdentifier(e.target.value); setError(''); }} + onFocus={() => setIdentifierFocused(true)} + onBlur={() => setIdentifierFocused(false)} className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" - placeholder="name@edr.com" + placeholder="name@edr.com, +251… or username" required - autoComplete="email" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + autoComplete="username" /> @@ -209,7 +214,7 @@ export default function LoginPage() {
+ + {disabled && ( + + Not available while your profile changes are under review. + + )} + + + + setConfirming(false)} + title="Switch to eTrade registration?" + centered + radius="lg" + > + + This re-opens your application: + + + The registration details you typed are cleared — eTrade supplies + them once your TIN is found. + + + Your company goes back to pending and is reviewed again. + + + Your documents, owner and contact details stay as they are. + + + }> + If eTrade holds no record for your TIN you won't be able to finish — + come back here and re-select the investment licence in the wizard. + + {error && ( + + {error} + + )} + + + + + + + + ); +} From 0f11d9518f9c299a2e1f1ca82be2910c1c2a34b2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 08:45:38 +0000 Subject: [PATCH 13/60] feat(backoffice): flag customers whose registration was typed, not fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two kinds of customer reach approval with a registration nobody checked: a co-operative union or farm, which holds no trade licence, and a foreign investor, whose licence comes from the Investment Commission rather than the trade registry. Both were reviewed on screens that read exactly like an eTrade-verified company's, with only a small Registration field naming the difference. They now carry an orange "Manual entry" badge in the customers list and beside the company name, and their overview opens with an alert saying the name, registration and address below are the customer's own statement — pointing the reviewer at the paper that stands in for the licence (the co-operative certificate, or the investment licence) before approving. Approval itself is not blocked. --- .../src/components/customers/badges.tsx | 30 +++++++++++++++++++ .../src/components/customers/index.ts | 1 + .../pages/customers/CustomerDetailPage.tsx | 28 ++++++++++++++++- .../src/pages/customers/CustomersPage.tsx | 5 ++++ .../backoffice/src/types/customer.ts | 7 +++++ 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 90dea5adb..22d3e66cf 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -114,6 +114,36 @@ export function CompanyNationalityBadge({ ); } +/** + * The company's registration was typed, not fetched from eTrade — nothing in it + * has been checked against a licence. Loud on purpose: it is the one thing a + * reviewer must not miss about this customer. Two kinds of company land here + * for different reasons, and the badge names which. + */ +export function ManualRegistrationBadge({ + cooperative, + investorLicence, +}: { + cooperative?: boolean | null; + investorLicence?: boolean | null; +}) { + if (!cooperative && !investorLicence) return null; + return ( + + {cooperative + ? "Manual entry · co-operative" + : "Manual entry · investment licence"} + + ); +} + /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) * carrying its reference code, colored by the profile's status (green active, diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 6f869173c..864a89ae6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -4,6 +4,7 @@ export { CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, + ManualRegistrationBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 40185f990..a81f07c8b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -50,6 +50,7 @@ import { CompanyTimeline, CompanyTypeBadge, InvoiceStatusBadge, + ManualRegistrationBadge, PaymentStatusBadge, PersonCard, ProfileApprovalActions, @@ -744,6 +745,10 @@ export default function CustomerDetailPage() { ) : ( )} + } @@ -792,6 +797,25 @@ export default function CustomerDetailPage() { )} + {/* Nothing below came from eTrade for these customers. A + co-operative holds no trade licence at all; a foreign investor's + comes from the Investment Commission, not the trade registry. + Either way every registration field was typed, and the reviewer + is the only check there is. */} + {(company.cooperative || company.investorLicence) && ( + } + title="Registration entered by hand — not verified against eTrade" + > + {company.cooperative + ? "This company onboarded as a co-operative union or farm, which holds no trade licence, so eTrade had no record to look its TIN up in. The company name, registration and address below are the customer's own statement. Check them against the Co-operative Registration Certificate on the Documents tab before approving." + : "This company onboarded on a foreign investment licence, so we could not look its TIN up on eTrade. The company name, registration and address below are the customer's own statement. Check them against the Investment Licence on the Documents tab before approving."} + + )} + diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 91bb72fdc..3825f0165 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -28,6 +28,7 @@ import { useNavigate } from "react-router-dom"; import { CompanyNationalityBadge, CompanyStatusBadge, + ManualRegistrationBadge, ProfileChips, formatDate, } from "@/components/customers"; @@ -142,6 +143,10 @@ export default function CustomersPage() { {c.name} + TIN {c.tin} diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 7fa7d792a..8c95e7342 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -234,6 +234,13 @@ export interface Company { * manager to check the owner against, and it holds no freight-forwarder role. */ cooperative?: boolean; + /** + * A foreign investor on an Ethiopian Investment Commission licence: eTrade + * holds no record for its TIN, so every registration field below was typed by + * the customer and verified by nobody. The reviewer is the check — compare + * them against the investment licence on the Documents tab. + */ + investorLicence?: boolean; address?: string | null; phone?: string | null; email?: string | null; From 0b8b9c39ab8064f966b0a428efbcf80d5eb8546f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 09:00:21 +0000 Subject: [PATCH 14/60] fix(companies): clear the typed registration when the manual-entry box is un-ticked Going back in the wizard and un-ticking co-operative or investment licence used to write the flag and nothing else. The registration the customer had typed stayed on the company row, so `hasRegistrationDetails` still read as a passed eTrade lookup, resume dropped them at their furthest step rather than the company one, and the application could be finished on unverified data with no flag left on it for the backoffice to show. That transition now costs what the settings switch costs: the eTrade-sourced columns and the manager captured beside them are cleared, and onboarding drops back to the company step so the TIN actually goes through eTrade. Both the reset payload and the attribute strip are now shared with `revertToRegularCompany`, which did this correctly already. --- .../companies.investor-licence.spec.ts | 64 +++++++++++++++ .../modules/companies/companies.service.ts | 77 +++++++++++++++---- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts index 4a17d9594..9623945d1 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts @@ -138,6 +138,70 @@ describe("the foreign investment-licence route", () => { }); }); + it("clears the typed registration when the box is un-ticked on the way back", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Foreign, + attributes: { investorLicence: true, etradeManagerName: "Typed Name" }, + region: "Addis Ababa", + licenceNumber: "TYPED-1", + }); + + await start(service, CompanyNationality.Foreign, false, false); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + // The wizard sends both flags; the typed manager does not survive. + expect(updates.attributes).toEqual({ + cooperative: false, + investorLicence: false, + }); + expect(updates.licenceNumber).toBeNull(); + expect(updates.region).toBeNull(); + // Resume must land back on the company step, or the customer never reaches + // the eTrade lookup they just opted back into. + expect(profilesRepo.update).toHaveBeenCalledWith("external-1", { + onboardingStep: "company", + }); + }); + + it("does the same for a co-operative that stops being one", async () => { + const { service, companiesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Ethiopian, + attributes: { cooperative: true }, + region: "Oromia", + }); + + await start(service, CompanyNationality.Ethiopian, false, false); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates.region).toBeNull(); + }); + + it("leaves the registration alone while the flag stays on", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + id: "company-1", + nationality: CompanyNationality.Foreign, + attributes: { investorLicence: true }, + region: "Addis Ababa", + }); + + await start(service, CompanyNationality.Foreign, false, true); + + const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [ + string, + Record, + ]; + expect(updates).not.toHaveProperty("region"); + expect(profilesRepo.update).not.toHaveBeenCalled(); + }); + it("refuses to switch a company that never took the investment-licence route", async () => { const { service } = makeService({ id: "company-1", attributes: {} }); await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf( 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 f9090f288..6dae48f24 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -424,9 +424,28 @@ export class CompaniesService { : {}), }; } + // Going back and un-ticking the box is the same act as the settings + // switch, so it has to cost the same: the registration the customer typed + // goes, and onboarding drops back to the company step. Without this the + // draft keeps the typed values, `hasRegistrationDetails` reads as a passed + // lookup, resume lands past the company step entirely — and the company + // finishes onboarding on unverified data with no flag left to say so. + const backToEtrade = + usesManualRegistration(current) && !isCoop && !isInvestor; + if (backToEtrade) { + Object.assign(updates, CompaniesService.CLEARED_REGISTRATION); + updates.attributes = this.withoutTypedEtradeManager( + updates.attributes ?? current?.attributes, + ); + } if (Object.keys(updates).length > 0) { await this.companiesRepo.update(companyId, updates); } + if (backToEtrade) { + await this.profilesRepo.update(existing.id, { + onboardingStep: "company", + }); + } return this.getCompanyInfoByUserId(identity.userId); } @@ -513,6 +532,45 @@ export class CompaniesService { } } + /** + * The registration block as it must look when nobody has verified it. + * + * Used wherever a company stops being one eTrade cannot answer for: whatever + * sits in these columns was the customer's own statement, and the wizard + * treats a populated registration as a lookup that already passed + * (`hasRegistrationDetails`). Leaving it behind would hand the company an + * eTrade-verified record eTrade never supplied — and, once the flag is gone, + * a backoffice screen that says so. + */ + private static readonly CLEARED_REGISTRATION: Partial = { + licenceNumber: null, + statusDescription: null, + dateRegistered: null, + renewedFrom: null, + renewalDate: null, + renewedTo: null, + region: null, + zone: null, + woreda: null, + kebele: null, + houseNo: null, + etradePhone: null, + }; + + /** + * The company's own `attributes`, minus the manager captured alongside a + * typed registration. It never came from a licence, so it must not outlive + * the registration it belonged to. + */ + private withoutTypedEtradeManager( + attributes: Record | null | undefined, + ): Record { + const next = { ...(attributes ?? {}) }; + delete next.etradeManagerName; + delete next.etradeManagerPhone; + return next; + } + /** * An investment licence belongs to a foreign company and to nothing else. * @@ -2457,28 +2515,13 @@ export class CompaniesService { ); } - const attributes = { ...(company.attributes ?? {}) }; + const attributes = this.withoutTypedEtradeManager(company.attributes); delete attributes[INVESTOR_LICENCE_KEY]; - // The manager captured alongside the (typed) registration goes with it — - // it never came from a licence, so it must not survive as one. - delete attributes.etradeManagerName; - delete attributes.etradeManagerPhone; await this.companiesRepo.update(companyId, { + ...CompaniesService.CLEARED_REGISTRATION, attributes, status: CompanyStatus.Pending, - licenceNumber: null, - statusDescription: null, - dateRegistered: null, - renewedFrom: null, - renewalDate: null, - renewedTo: null, - region: null, - zone: null, - woreda: null, - kebele: null, - houseNo: null, - etradePhone: null, }); await this.profilesRepo.update(profile.id, { onboardingCompleted: false, From 333232c4d9da86c3555dfc077313236f5990c7a9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 11:37:43 +0000 Subject: [PATCH 15/60] fix(portal): persist the region a manual-registration company picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Region select called setValue without shouldDirty. `region` is an eTrade-bundle key, and stepPayload sends those only when the customer changed them this session — so for the two routes that type their address by hand (a co-operative, a foreign investor) the region was dropped on every save while zone, woreda and kebele went through, because those are registered inputs and are dirty by construction. Found by the new onboarding e2e suite: both manual-route companies finished onboarding with zone/woreda/kebele on file and region empty. --- .../steps/CompanyInfoStep.tsx | 10 +- .../cypress/e2e/flows/onboarding.cy.ts | 276 ------------------ 2 files changed, 9 insertions(+), 277 deletions(-) delete mode 100644 e2e/freight/cypress/e2e/flows/onboarding.cy.ts diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx index 04a91bf07..52893ec59 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -159,7 +159,15 @@ export default function CompanyInfoStep({ searchable value={region || null} onChange={(v) => - setValue("region", v ?? "", { shouldValidate: true }) + // shouldDirty, or the pick never reaches the API: `region` is + // an eTrade-bundle key, and `stepPayload` sends those only + // when the customer changed them this session. Without it a + // co-operative or foreign investor typed its address and the + // region alone silently vanished on save. + setValue("region", v ?? "", { + shouldValidate: true, + shouldDirty: true, + }) } error={errors.region?.message} /> diff --git a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts deleted file mode 100644 index 437c0e92e..000000000 --- a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Full customer onboarding journey, both apps: - * - * 1. portal — /signup form → OTP (read from DB, delivery is off in e2e) - * → account created → onboarding wizard (nationality/role → - * company → personnel → contact → PoA → documents incl. the - * per-role business license) → "Submit for review" - * 2. backoffice — staff (chief, holds edr_freight_app:admin) approves the - * importer profile on /dashboard/customers/:id - * 3. portal — the new customer is active: contract wizard reachable - * - * Tests are sequential steps of ONE journey (fresh unique user per run), so - * retries are disabled — a mid-journey retry would replay a non-idempotent - * step against already-advanced state. - * - * NOTE: switching origin between tests (portal 5373 ↔ backoffice 5383) - * reloads the spec bundle and resets module state — later tests resolve the - * journey's user/company from the DB instead of module variables. - */ - -import { completeFaydaVerification } from "./import-utils"; - -const stamp = Date.now(); -const email = `e2e.onboard.${stamp}@example.com`; -// Ethiopian mobile: 9 + 8 digits, unique per run. -const phoneNational = `9${String(stamp).slice(-8)}`; -const signupPassword = "Password@e2e1"; -const tin = String(stamp).slice(-10).padStart(10, "1"); -const vat = String(stamp + 1).slice(-10).padStart(10, "2"); - -const portal = () => Cypress.env("portalUrl") as string; - -/** The journey's company/user = the latest e2e.onboard.* signup in the DB. */ -function latestOnboardJourney() { - return cy.task<{ rows: Array<{ name: string; email: string }> }>("db:query", { - sql: `SELECT c.name, u.email - FROM freight.companies c - JOIN freight.external_profiles ep ON ep.company_id = c.id - JOIN iam.users u ON u.id = ep.user_id - WHERE u.email LIKE 'e2e.onboard.%' - ORDER BY c.created_at DESC LIMIT 1`, - }); -} - -/** - * Fill a labelled Mantine input (label[for] → input id). - * - * The input is resolved fresh for every action rather than captured once. - * Each wizard step persists and re-seeds asynchronously, and when a field - * remounts Mantine mints a NEW generated id — so both a subject and an id - * captured a command earlier can be stale by the time the next command runs. - * Going label → for → element each time always addresses what's on the page - * now. - */ -function fill(label: string | RegExp, value: string) { - const input = () => - cy - .contains("label", label) - .invoke("attr", "for") - .then((id) => cy.get(`[id="${id}"]`)); - - input().clear({ force: true }); - input().type(value, { force: true }); -} - -/** - * Fill an input that has no
@@ -221,55 +291,42 @@ export default function AuditLogsPage() { onChange={(e) => setFilters({ ...filters, entityType: e.target.value })} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {entityTypeOptions.map((t) => ( + + ))}
-
- setFilters({ search: '', action: '', entityType: '' })} - className="w-full" - > - Clear Filters - +
+ + setFilters({ ...filters, from: e.target.value })} + />
+
+ + setFilters({ ...filters, to: e.target.value })} + /> +
+
+
+ setFilters({ search: '', action: '', entityType: '', from: '', to: '' })} + > + Clear Filters +
- {/* Data Table */} + {/* Pagination */} +
+

+ {total === 0 + ? 'No results' + : `Showing ${page * PAGE_SIZE + 1}–${Math.min((page + 1) * PAGE_SIZE, total)} of ${total}`} +

+
+ setPage((p) => Math.max(0, p - 1))} + > + Previous + + + Page {page + 1} of {pageCount} + + = pageCount} + onClick={() => setPage((p) => p + 1)} + > + Next + +
+
+ {/* Details Modal */} { setShowDetailsModal(false); setSelectedLog(null); }} + onClose={() => { + setShowDetailsModal(false); + setSelectedLog(null); + }} title="Audit Log Details" size="xl" > - {selectedLog && (() => { - const l = selectedLog; - const actionColor: Record = { - CREATE: 'from-emerald-600 to-emerald-700', - UPDATE: 'from-blue-600 to-blue-700', - DELETE: 'from-red-600 to-red-700', - LOGIN: 'from-violet-600 to-violet-700', - LOGOUT: 'from-gray-600 to-gray-700', - }; - const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700'; + {selectedLog && + (() => { + const l: AuditLog = selectedLog; + const gradient = CREATIVE_ACTIONS.has(l.action) + ? 'from-emerald-600 to-emerald-700' + : DESTRUCTIVE_ACTIONS.has(l.action) + ? 'from-red-600 to-red-700' + : 'from-blue-600 to-blue-700'; - const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( -
-

{label}

-

{value || '—'}

-
- ); - - const SectionHeader = ({ title }: { title: string }) => ( -

- {title} -

- ); - - return ( -
-
-
-
-

Action

-

{l.action}

-
-
- {l.entityType} -

{formatDateTime(l.createdAt)}

-
-
-
-
-

User

-

{l.user?.fullName || 'System'}

-
-
-

IP Address

-

{l.ipAddress || 'N/A'}

-
-
+ const Field = ({ + label, + value, + mono = false, + truncate = false, + }: { + label: string; + value?: string; + mono?: boolean; + truncate?: boolean; + }) => ( +
+

{label}

+

+ {value || '—'} +

+ ); -
-
- -
- - - - + const SectionHeader = ({ title }: { title: string }) => ( +

+ + {title} +

+ ); + + return ( +
+
+
+
+

Action

+

{l.action}

+
+
+ + {l.entityType} + +

{formatDateTime(l.createdAt)}

+
-
+
+
+

User

+

{actorName(l)}

+
+
+

IP Address

+

{l.ipAddress || 'N/A'}

+
+
+
+ +
+
+ +
+ + + + +
+
- {l.user && (
- - - + + +
- )} - {(l.ipAddress || l.userAgent) && ( -
- -
- -
-

User Agent

-

{l.userAgent || '—'}

+ {(l.ipAddress || l.userAgent) && ( +
+ +
+ +
+

User Agent

+

+ {l.userAgent || '—'} +

+
-
-
- )} + + )} + + {(l.oldData || l.newData) && ( +
+ +
+ {l.oldData && ( +
+

+ ← Before +

+
+                              {formatJsonData(l.oldData)}
+                            
+
+ )} + {l.newData && ( +
+

+ → After +

+
+                              {formatJsonData(l.newData)}
+                            
+
+ )} +
+
+ )} - {(l.oldData || l.newData) && (
- -
- {l.oldData && ( -
-

← Before

-
-                            {formatJsonData(l.oldData)}
-                          
-
- )} - {l.newData && ( -
-

→ After

-
-                            {formatJsonData(l.newData)}
-                          
-
- )} + +
+
- )} +
-
- -
- -
-
+
+ { + setShowDetailsModal(false); + setSelectedLog(null); + }} + > + Close + +
- -
- { setShowDetailsModal(false); setSelectedLog(null); }}>Close -
-
- ); - })()} + ); + })()}
); } + +export default function AuditLogsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index f528f9dff..7350928b3 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -380,7 +380,11 @@ export const verifaydaApi = { // Audit API export const auditApi = { getLogs: async (params?: any) => { - const query = new URLSearchParams(params as Record).toString(); + // Drop empty filters so a blank search box doesn't send `search=` and match nothing. + const entries = Object.entries(params ?? {}).filter( + ([, v]) => v !== undefined && v !== null && v !== '', + ); + const query = new URLSearchParams(entries as [string, string][]).toString(); const response = await apiClient.get(`/audit/logs${query ? `?${query}` : ''}`); if (response?.data) { return Array.isArray(response.data) ? { items: response.data } : response; @@ -388,6 +392,7 @@ export const auditApi = { return Array.isArray(response) ? { items: response } : response; }, getLog: (id: string) => apiClient.get(`/audit/logs/${id}`), + getVocabulary: () => apiClient.get('/audit/vocabulary'), }; // Live Tracking API diff --git a/apps/edr-passenger-web/backoffice/src/types/edr.ts b/apps/edr-passenger-web/backoffice/src/types/edr.ts index 1b5ca7a1e..fa864eb71 100644 --- a/apps/edr-passenger-web/backoffice/src/types/edr.ts +++ b/apps/edr-passenger-web/backoffice/src/types/edr.ts @@ -301,7 +301,11 @@ export interface FraudRule { // Audit Types export interface AuditLog { id: string; - userId?: string; + /** IAM id of the staff member who performed the action. The API field is `iamUserId`. */ + iamUserId?: string; + /** Actor's name and phone, denormalized by the API at write time. */ + userName?: string; + userPhone?: string; action: string; entityType: string; entityId?: string; From cbcc9a02e69d6e5084e528dc5b3421d01c422a8c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 12:44:02 +0000 Subject: [PATCH 24/60] fix: approval window --- .../companies.license-supersede.spec.ts | 187 ++++++++++++++++++ .../companies.profile-approval.spec.ts | 115 +++++++++++ .../modules/companies/companies.service.ts | 59 +++++- .../src/components/customers/badges.tsx | 43 ++-- 4 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts new file mode 100644 index 000000000..5a73b6040 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts @@ -0,0 +1,187 @@ +import { CompaniesService } from "./companies.service"; +import { CompanyStatus } from "./entities/company.entity"; +import { + ProfileStatus, + ProfileType, +} from "./entities/company-profile.entity"; + +/** + * Uploading a business licence only ever adds a row — nothing overwrites. So a + * customer answering a rejection or a document correction used to end up with + * the refused licence still listed beside the new one, in the portal and in the + * backoffice, with nothing saying which is current. An upload that answers a + * reviewer now retires what it answers; an upload with nothing outstanding is a + * genuine addition and still just adds. + */ +interface StoredFile { + id: string; + name: string; + code: string; + createdAt: Date; + reviewStatus?: string | null; + removed?: boolean; +} + +const T0 = new Date("2026-01-01T00:00:00Z"); +const REJECTED_AT = new Date("2026-02-01T00:00:00Z"); +const T2 = new Date("2026-03-01T00:00:00Z"); + +function makeService( + status: ProfileStatus, + files: StoredFile[], + reviewedAt: Date | null = null, +) { + const stored = [...files]; + const profile = { + id: "profile-1", + companyId: "company-1", + type: ProfileType.importer, + status, + reviewedAt, + }; + const company = { + id: "company-1", + status: + status === ProfileStatus.Active + ? CompanyStatus.Active + : CompanyStatus.Pending, + companyProfiles: [profile], + }; + + const live = () => stored.filter((f) => !f.removed); + const filesService = { + upload: jest.fn(async (input: { code: string; file: { originalname: string } }) => { + const record = { + id: `file-${stored.length + 1}`, + name: input.file.originalname, + code: input.code, + createdAt: T2, + size: 1, + mimeType: "application/pdf", + }; + stored.push(record); + return record; + }), + findByResource: jest.fn(async () => live()), + findWithOpenChangeRequest: jest.fn(async () => + live().filter((f) => f.reviewStatus === "change_requested"), + ), + findById: jest.fn(async (id: string) => ({ + ...stored.find((f) => f.id === id), + resource: "company_profiles", + resourceId: "profile-1", + })), + remove: jest.fn(async (id: string) => { + const found = stored.find((f) => f.id === id); + if (found) found.removed = true; + }), + clearReview: jest.fn(async (id: string) => { + const found = stored.find((f) => f.id === id); + if (found) found.reviewStatus = null; + }), + }; + + const changeRequestRepo = { + findPendingByCompanyId: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ id: "cr-1", ...row })), + update: jest.fn(async () => ({ id: "cr-1" })), + findByCompanyId: jest.fn(async () => []), + }; + + const service = new CompaniesService( + { findById: jest.fn(async () => company) } as never, + { findByCompanyId: jest.fn(async () => [profile]) } as never, + changeRequestRepo as never, + {} as never, + { findByCompanyId: jest.fn(async () => []) } as never, + {} as never, + filesService as never, + {} as never, + {} as never, + { changeRequestSubmitted: jest.fn() } as never, + {} as never, + {} as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => ({ profile: { id: "external-1" }, company }) as never, + ); + + return { service, stored, live, filesService, changeRequestRepo }; +} + +const upload = (service: CompaniesService) => + service.addProfileLicenseFiles("user-1", "profile-1", [ + { originalname: "new-licence.pdf" } as never, + ]); + +describe("a business licence uploaded to answer a reviewer", () => { + it("retires the file the reviewer flagged for correction", async () => { + const { service, live } = makeService(ProfileStatus.Pending, [ + { id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" }, + ]); + + await upload(service); + + expect(live().map((f) => f.name)).toEqual(["new-licence.pdf"]); + }); + + it("retires what was on file when the role was rejected, but not the customer's own fix so far", async () => { + // Two uploads answering one rejection (a second page, or a re-pick) must not + // cannibalise each other — only what the reviewer actually refused goes. + const { service, live } = makeService( + ProfileStatus.Rejected, + [ + { id: "file-refused", name: "refused.pdf", code: "business_license", createdAt: T0 }, + { id: "file-fix-1", name: "fix-page-1.pdf", code: "business_license", createdAt: T2 }, + ], + REJECTED_AT, + ); + + await upload(service); + + expect(live().map((f) => f.name)).toEqual([ + "fix-page-1.pdf", + "new-licence.pdf", + ]); + }); + + it("leaves an ordinary addition alone when nothing was asked for", async () => { + const { service, live } = makeService(ProfileStatus.Pending, [ + { id: "file-old", name: "existing.pdf", code: "business_license", createdAt: T0 }, + ]); + + await upload(service); + + expect(live().map((f) => f.name)).toEqual([ + "existing.pdf", + "new-licence.pdf", + ]); + }); + + it("stages the swap for review on an approved role instead of deleting", async () => { + // A live role's licence is not the customer's to remove unilaterally: the + // old file stays until a reviewer approves the swap. + const { service, live, changeRequestRepo } = makeService( + ProfileStatus.Active, + [ + { id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" }, + ], + ); + + await upload(service); + + expect(live().map((f) => f.name)).toEqual(["old.pdf", "new-licence.pdf"]); + const intents = changeRequestRepo.create.mock.calls.flatMap( + ([row]) => (row as any).documents.licenseChanges, + ); + expect(intents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ op: "add", fileId: "file-2" }), + expect.objectContaining({ op: "remove", fileId: "file-old" }), + ]), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts new file mode 100644 index 000000000..79fbdd479 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts @@ -0,0 +1,115 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { Company, CompanyStatus } from "./entities/company.entity"; +import { + CompanyProfile, + ProfileStatus, + ProfileType, +} from "./entities/company-profile.entity"; + +/** + * A rejection hands the role back to the customer: they fix what was flagged + * and resubmit (`reapplyCompanyProfile` → Pending). The reviewer used to be + * able to skip that entirely and approve straight out of Rejected — granting + * the role over the documents that were just refused, while the customer's + * "please fix this" note was still on their screen. + */ +function makeService(status: ProfileStatus) { + const profile: Partial = { + id: "profile-1", + companyId: "company-1", + type: ProfileType.importer, + status, + reference: null, + reviewNote: status === ProfileStatus.Rejected ? "Licence expired" : null, + }; + const company = { + id: "company-1", + status: CompanyStatus.Pending, + attributes: {}, + }; + + const written: Partial[] = []; + const profileRepo = { + update: jest.fn(async (_id: string, patch: Partial) => { + written.push(patch); + Object.assign(profile, patch); + return null; + }), + findOne: jest.fn(async () => profile), + }; + const companyRepo = { findOne: jest.fn(async () => company), update: jest.fn() }; + + const companyProfilesRepo = { + findById: jest.fn(async () => profile), + generateReference: jest.fn(async () => "IM-A00001"), + }; + const profilesRepo = { + // Onboarding submitted — the other gate in this method is not what these + // tests are about. + findByCompanyId: jest.fn(async () => [{ onboardingCompleted: true }]), + }; + const filesService = { findWithOpenChangeRequest: jest.fn(async () => []) }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => + cb({ + findOne: jest.fn(async () => company), + getRepository: (entity: unknown) => + entity === Company ? companyRepo : profileRepo, + }), + ), + }; + const companyNotifier = { profileStatusChanged: jest.fn(), companyApproved: jest.fn() }; + + const service = new CompaniesService( + {} as never, + companyProfilesRepo as never, + {} as never, + {} as never, + profilesRepo as never, + {} as never, + filesService as never, + {} as never, + {} as never, + companyNotifier as never, + dataSource as never, + {} as never, + ); + + return { service, profile, written, companyProfilesRepo }; +} + +describe("approving an operational role", () => { + it("refuses to approve a role the customer has not resubmitted", async () => { + const { service, companyProfilesRepo } = makeService(ProfileStatus.Rejected); + + await expect( + service.setCompanyProfileStatus("profile-1", ProfileStatus.Active), + ).rejects.toBeInstanceOf(BadRequestException); + // Refused before any reference could be minted against the rejected role. + expect(companyProfilesRepo.generateReference).not.toHaveBeenCalled(); + }); + + it("lets a reviewer undo their own rejection, and drops the note with it", async () => { + const { service, written } = makeService(ProfileStatus.Rejected); + + await service.setCompanyProfileStatus("profile-1", ProfileStatus.Pending); + + expect(written[0]).toMatchObject({ + status: ProfileStatus.Pending, + reviewNote: null, + }); + }); + + it("still approves a role that is awaiting its first decision", async () => { + const { service, written } = makeService(ProfileStatus.Pending); + + await service.setCompanyProfileStatus("profile-1", ProfileStatus.Active); + + expect(written[0]).toMatchObject({ + status: ProfileStatus.Active, + reference: "IM-A00001", + }); + }); +}); 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 1eb611c24..d7151f200 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1830,6 +1830,24 @@ export class CompaniesService { ); } + // A rejected role is waiting on the customer, not on the reviewer: nothing + // has been resubmitted, and the note telling them what to fix is still on + // their screen. Approving straight out of Rejected grants the very role that + // was refused, over the documents that were refused with it. The way back is + // the customer's own resubmission (`reapplyCompanyProfile` → Pending); a + // rejection made in error is undone by moving the role back to pending + // review first — the same shape as "withdraw the change request first" on + // the document gate below. + if ( + status === ProfileStatus.Active && + existing.status === ProfileStatus.Rejected + ) { + throw new BadRequestException( + "This role was rejected — the customer has to fix what was flagged and resubmit it before it can be approved. " + + "If the rejection was a mistake, move the role back to pending review first.", + ); + } + // A self-registered company is only reviewable once its owner submits the // onboarding wizard (markOnboardingComplete) — until then its profiles are // half-filled drafts and approving one would mint a reference against an @@ -1958,7 +1976,14 @@ export class CompaniesService { status === ProfileStatus.Suspended ) { patch.reviewNote = note ?? null; - } else if (status === ProfileStatus.Active) { + } else if ( + status === ProfileStatus.Active || + status === ProfileStatus.Pending + ) { + // Pending only reaches here when a reviewer withdraws their own rejection + // (the customer's resubmission clears the note in `reapplyCompanyProfile`), + // so the reason they gave goes with it — leaving it would keep telling the + // customer to fix something nobody is waiting on any more. patch.reviewNote = null; } if (status !== ProfileStatus.Pending) { @@ -2675,6 +2700,9 @@ export class CompaniesService { * with the role itself. Only for an already-approved role are they staged under * the pending code and recorded as `add` intents on a pending change request — * a licence swap on a live role is a change; a licence on a new role is not. + * + * An upload that answers a reviewer also retires the licence it answers (see + * below), so a correction never leaves both copies on file. */ async addProfileLicenseFiles( userId: string, @@ -2686,6 +2714,28 @@ export class CompaniesService { const gated = profile.status === ProfileStatus.Active; const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE; + // An upload that answers the reviewer replaces what they refused; it does + // not sit next to it. Uploading only ever adds a row, so without this the + // refused licence stays listed in the portal and the backoffice beside the + // new one and nothing says which is current. Two things count as refused: + // the file the reviewer flagged for correction, and — when the whole role + // came back rejected — every licence that was already on file when they + // rejected it. Anything the customer uploaded *since* that decision is part + // of the same fix (a second page, a re-pick), so it survives, and an upload + // with nothing outstanding is a genuine addition and is left alone. + const rejectedAt = + profile.status === ProfileStatus.Rejected + ? (profile.reviewedAt ?? null) + : null; + const superseded = rejectedAt + ? ( + await this.filesService.findByResource(profileId, LICENSE_RESOURCE) + ).filter((f) => f.createdAt < rejectedAt) + : await this.filesService.findWithOpenChangeRequest( + [profileId], + LICENSE_RESOURCE, + ); + const uploaded = await Promise.all( files.map((file) => this.filesService.upload({ @@ -2710,6 +2760,13 @@ export class CompaniesService { ); } + // Retire what the upload supersedes, through the normal removal path so an + // approved role stages a `remove` intent (reviewed as a swap) while an + // unapproved one just drops the file. + for (const stale of superseded) { + await this.removeProfileLicenseFile(userId, profileId, stale.id); + } + // A fresh licence upload answers any correction the reviewer asked for on the // previous one, so the old row must stop blocking approval. await this.resolveDocumentChangeRequests( diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 22d3e66cf..3127a39f8 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -338,9 +338,11 @@ export function InvoiceStatusBadge({ /** * Inline approval action buttons for a profile row. - * Transitions: pending → approve / reject-with-note | rejected → approve (override) | + * Transitions: pending → approve / reject-with-note | rejected → undo-rejection (→ pending) | * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate. - * Rejecting captures a note the customer sees so they can fix and reapply. + * Rejecting captures a note the customer sees so they can fix and reapply — a + * rejected role is theirs to resubmit, so it cannot be approved from here until + * they do (the API refuses it); undoing the rejection is the only way back. * * `locked` (customer hasn't submitted onboarding) withholds the review decision * only — there's no application to judge yet, and the API rejects the call @@ -526,18 +528,33 @@ export function ProfileApprovalActions({ } if (status === "rejected") { - if (!canSet("active")) return null; + // No Approve here: the role is waiting on the customer to fix what was + // flagged and resubmit it, and the API refuses rejected → active outright. + // All that's left is undoing a rejection that shouldn't have happened, + // which puts the role back in the queue rather than into service. + if (!canSet("pending")) return null; return ( - + + + Awaiting customer resubmission + + + + + ); } From 22a3fb98eec99fed9bc52595b3d5fe32e06643ca Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 18 Aug 2026 12:50:35 +0000 Subject: [PATCH 25/60] feat: Implement consolidated booking functionality - Added support for viewing and managing consolidated bookings in BookingRequestDetailPage. - Enhanced BookingRequestsPage to display paired bookings in a single row. - Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair. - Updated contracts service to include methods for manual consolidation of odd-20ft bookings. - Created new components for selecting and editing consolidation partners. - Added tests for paired decision logic and manual consolidation scenarios. - Updated UI to reflect changes in booking handling and provide user feedback for odd container counts. --- ...booking-transition.paired-decision.spec.ts | 132 ++++ .../bookings/booking-transition.service.ts | 69 +++ .../modules/bookings/bookings.controller.ts | 26 + .../modules/bookings/bookings.repository.ts | 85 +++ .../bookings/dto/request-changes.dto.ts | 33 + ...tract-booking.manual-consolidation.spec.ts | 213 +++++++ .../contracts/contract-booking.service.ts | 156 +++++ .../modules/contracts/contracts.controller.ts | 40 +- .../dto/create-booking-under-contract.dto.ts | 44 +- .../bookings/BookingActionsMenu.tsx | 32 +- .../bookings/BookingConfirmDialog.tsx | 24 + .../bookings/useBookingActionDialog.ts | 37 ++ .../contracts/GlCreateBookingForm.tsx | 566 +++++++++++++++++- .../ConsolidationPartnerPanel.tsx | 255 ++++++++ .../ConsolidationPartnerPicker.tsx | 137 +++++ .../backoffice/src/constants/URLS.ts | 8 + .../bookings/booking-actions.config.ts | 3 + .../src/hooks/bookings/useBookings.ts | 32 + .../bookings/BookingRequestDetailPage.tsx | 83 ++- .../pages/bookings/BookingRequestsPage.tsx | 43 +- .../src/services/bookings.service.ts | 20 + .../src/services/contracts.service.ts | 66 ++ .../backoffice/src/types/booking.ts | 6 + .../new-booking-form/step8-review.tsx | 26 +- .../contracts/NewShipmentRequestPage.tsx | 19 +- 25 files changed, 2121 insertions(+), 34 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts new file mode 100644 index 000000000..91b1bfd91 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts @@ -0,0 +1,132 @@ +import { BookingTransitionService } from './booking-transition.service'; +import { Booking } from './entities/booking.entity'; + +/** + * Staff decisions on a consolidated pair. Two bookings sharing a wagon must move + * together: accepting one alone would put half a wagon into the approval chain, + * and cancelling one alone would strand the other on a wagon it can no longer + * fill. All-or-nothing — if either half throws, neither booking moved. + */ +describe('BookingTransitionService — paired staff decisions', () => { + function makeService(booking: Partial) { + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking as Booking), + }; + // Runs the callback so a throw propagates, which is what the all-or-nothing + // guarantee reduces to from this service's point of view. + const dataSource = { + transaction: jest.fn(async (cb: () => Promise) => cb()), + }; + + const service = new BookingTransitionService( + {} as never, // bookingsRepository + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + {} as never, // bookingBatchService + bookingsService as never, + {} as never, // bookingClearanceService + {} as never, // workflowService + {} as never, // invoiceService + {} as never, // containerValidationService + {} as never, // notifier + {} as never, // events + undefined, // milestoneService + dataSource as never, + ); + return { service, dataSource }; + } + + const paired = { + id: 'b-1', + reference: 'BK-1', + consolidationPartnerId: 'b-2', + } as Booking; + + it('accepts both halves with the same validity window', async () => { + const { service } = makeService(paired); + const accept = jest + .spyOn(service, 'acceptIntake') + .mockImplementation(async (id) => ({ id }) as Booking); + + const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', { + validityDays: 30, + }); + + expect(accept).toHaveBeenCalledTimes(2); + expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30); + expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30); + expect(result.booking.id).toBe('b-1'); + expect(result.partner.id).toBe('b-2'); + }); + + it('cancels both halves with the same reason', async () => { + const { service } = makeService(paired); + const cancel = jest + .spyOn(service, 'cancel') + .mockImplementation(async (id) => ({ id }) as Booking); + + await service.applyPairedDecision('b-1', 'cancel', 'staff-1', { + reason: 'customer withdrew', + }); + + expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew'); + expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew'); + }); + + it('propagates a failure on the second half so neither is committed', async () => { + const { service, dataSource } = makeService(paired); + jest + .spyOn(service, 'cancel') + .mockImplementationOnce(async (id) => ({ id }) as Booking) + .mockImplementationOnce(async () => { + throw new Error('partner is already in transit'); + }); + + await expect( + service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), + ).rejects.toThrow('partner is already in transit'); + + // Both halves ran inside one transaction, so the throw rolls the first back. + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + }); + + it('refuses a booking that has no partner', async () => { + const { service } = makeService({ + id: 'b-1', + consolidationPartnerId: null, + } as Booking); + + await expect( + service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), + ).rejects.toThrow(/no consolidation partner/i); + }); + + it('requires a validity window to accept', async () => { + const { service } = makeService(paired); + const accept = jest.spyOn(service, 'acceptIntake'); + + await expect( + service.applyPairedDecision('b-1', 'accept', 'staff-1', {}), + ).rejects.toThrow(/validity/i); + expect(accept).not.toHaveBeenCalled(); + }); + + it('routes operationAccept through the operation review on both halves', async () => { + const { service } = makeService(paired); + const review = jest + .spyOn(service, 'reviewOperationRequest') + .mockImplementation(async (id) => ({ id }) as Booking); + + await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {}); + + expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', { + note: undefined, + }); + expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', { + note: undefined, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2f50d0f38..556ce0e98 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -455,6 +455,75 @@ export class BookingTransitionService { return this.cancel(bookingId, reason ?? "Customer cancelled before payment"); } + /** + * Run a staff decision across BOTH halves of a consolidated pair. + * + * Two bookings that share a wagon must move together: accepting one while the + * other stays behind would put half a wagon into the approval chain, and + * cancelling one alone would strand the other on a wagon it can no longer + * fill. All-or-nothing — if either half throws, the transaction rolls back and + * neither booking moved. + * + * Each half still runs the ordinary single-booking transition, so pricing, + * invoicing and notifications stay per booking: the customers are billed and + * notified separately, exactly as they are today. + */ + async applyPairedDecision( + bookingId: string, + decision: "accept" | "cancel" | "operationAccept" | "requestChanges", + actorId: string, + options: { reason?: string; note?: string; validityDays?: number } = {}, + ): Promise<{ booking: Booking; partner: Booking }> { + const booking = await this.bookingsService.findById(bookingId); + const partnerId = booking.consolidationPartnerId; + if (!partnerId) { + throw new BadRequestException( + "This booking has no consolidation partner — use the single-booking action.", + ); + } + + const runOne = async (id: string): Promise => { + switch (decision) { + case "accept": + // Same requirement as the single-booking accept: the approval chain + // needs a contract validity window. + if (!(Number(options.validityDays) > 0)) { + throw new BadRequestException( + "Contract validity (days) is required to accept.", + ); + } + return this.acceptIntake(id, actorId, Number(options.validityDays)); + case "cancel": + return this.cancel( + id, + options.reason ?? "Cancelled with its consolidation partner", + ); + case "operationAccept": + return this.reviewOperationRequest(id, "ACCEPT", actorId, { + note: options.note, + }); + case "requestChanges": + return this.requestChanges(id, options.note ?? "", actorId); + } + }; + + // Without a DataSource (unit tests hand-construct this service) fall back to + // running the two halves directly — the ordering guarantee still holds, only + // the rollback does not. + if (!this.dataSource) { + const own = await runOne(bookingId); + const other = await runOne(partnerId); + return { booking: own, partner: other }; + } + + return this.dataSource.transaction(async () => { + // Sequential: one connection per transaction context. + const own = await runOne(bookingId); + const other = await runOne(partnerId); + return { booking: own, partner: other }; + }); + } + async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ 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 9c4b69ad6..c2d6465c2 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -59,6 +59,7 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, CancelBookingDto, + PairedDecisionDto, RejectBookingDto, RequestChangesDto, ReviewDocumentDto, @@ -1541,6 +1542,31 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(":id/paired-decision") + @BookingStaff(FREIGHT_PERMS.bookings.cancel) + @ApiOperation({ + summary: + "Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", + }) + async pairedDecision( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: PairedDecisionDto, + @CurrentUser() user: AuthUserPayload, + ) { + const { booking, partner } = await this.transitionService.applyPairedDecision( + id, + dto.decision, + resolveAuthUserId(user), + { reason: dto.reason, note: dto.note, validityDays: dto.validityDays }, + ); + // Sequential enrichment: both go back so the UI can refresh either tab. + const enrichedBooking = + await this.transitionService.enrichBookingResponse(booking); + const enrichedPartner = + await this.transitionService.enrichBookingResponse(partner); + return { booking: enrichedBooking, partner: enrichedPartner }; + } + @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) @ApiOperation({ summary: "Cancel booking" }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 310364927..7b042e0c7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -308,6 +308,72 @@ export class BookingsRepository extends BaseRepository { .find({ where: { contractId } }); } + /** + * Bookings a GL operator may manually link to `booking` as its odd-20ft + * consolidation partner (Path B customs flow). Unlike + * {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact + * quantity complement — this lists CANDIDATES for a human to choose from, so + * the filter is deliberately looser: any other customs booking on the same + * route/direction that is itself carrying an odd 20ft count. Two odd counts + * always sum to even, so any pick fills the shared wagon. + * + * Bare instances awaiting completion have no persisted containers yet, so the + * odd-count test runs on the requested container lines when they exist and the + * booking is offered as a candidate when they do not (GL enters its cargo on + * the split form). + */ + async findManualConsolidationCandidates( + booking: Booking, + limit = 50, + ): Promise { + const rows = await this.repository + .createQueryBuilder('b') + .leftJoinAndSelect('b.bookingContainers', 'bc') + .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('b.company', 'company') + .where('b.id != :bookingId', { bookingId: booking.id }) + // Never offer a booking that already shares a wagon with someone else. + .andWhere('b.consolidationPartnerId IS NULL') + // Customs-only: this manual flow exists because a customs (Path B) + // instance is completed by GL, not by the customer. + .andWhere('b.customsClearingEnabled = true') + // Same physical wagon ⇒ same route and same direction. + .andWhere('b.originYardId = :originYardId', { + originYardId: booking.originYardId, + }) + .andWhere('b.destinationYardId = :destinationYardId', { + destinationYardId: booking.destinationYardId, + }) + .andWhere('b.tradeDirection = :tradeDirection', { + tradeDirection: booking.tradeDirection, + }) + // Bookable = clearance finished and the booking is waiting to be completed, + // the same set completeUnderContract accepts, plus one already parked for a + // partner. + .andWhere('b.status IN (:...statuses)', { + statuses: [ + 'CLEARANCE_READY', + 'OPERATION_CHANGES_REQUESTED', + 'PENDING_CONSOLIDATION', + ], + }) + .orderBy('b.createdAt', 'ASC') + .take(limit) + .getMany(); + + // Odd-20ft test in memory: a bare instance has no containers yet (GL fills + // them on the split form) and stays a candidate; one that already carries + // cargo qualifies only when its 20ft total is odd. + return rows.filter((row) => { + const lines = row.bookingContainers ?? []; + if (lines.length === 0) return true; + const ft20 = lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + return ft20 % 2 === 1; + }); + } + /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). Only 20ft lines ever @@ -508,6 +574,25 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** + * Link two bookings as consolidation partners WITHOUT touching their statuses. + * Used by the manual GL pairing, where both bookings have just been completed + * into their live status — unlike {@link pairConsolidation}, which exists to + * resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part + * of that resume. + */ + async linkConsolidationPartners( + bookingId: string, + partnerId: string, + ): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: partnerId, + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: bookingId, + } as never); + } + /** Un-pair a consolidation. */ async unpairConsolidation(bookingId: string, partnerId: string): Promise { await this.repository.update(bookingId, { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 63ad5b9f4..9c4e5c944 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -125,3 +125,36 @@ export class OperationReviewDto { @IsString() note?: string; } + +/** + * A staff decision applied to BOTH halves of a consolidated pair. The two + * bookings share a wagon, so they advance or cancel together — never one alone. + */ +export class PairedDecisionDto { + @ApiProperty({ + enum: ["accept", "cancel", "operationAccept", "requestChanges"], + description: 'Which staff decision to apply to both bookings.', + }) + @IsIn(["accept", "cancel", "operationAccept", "requestChanges"]) + decision!: "accept" | "cancel" | "operationAccept" | "requestChanges"; + + @ApiPropertyOptional({ description: "Cancellation reason (decision=cancel)." }) + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional({ + description: "Message to the customer (decision=requestChanges).", + }) + @IsOptional() + @IsString() + note?: string; + + @ApiPropertyOptional({ + description: "Contract validity window in days (decision=accept).", + }) + @IsOptional() + @IsInt() + @Min(1) + validityDays?: number; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts new file mode 100644 index 000000000..841baab31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -0,0 +1,213 @@ +import { ContractBookingService } from './contract-booking.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes + * the booking, so GL also picks who shares its wagon: two bookings each carrying + * an odd 20ft count are completed together onto one wagon. + * + * The two invariants that matter are that the pair is all-or-nothing (a failure + * on either half must leave NEITHER booking completed and no link written) and + * that the two bookings stay financially separate — one completion each, so one + * price and one invoice each. + */ +describe('ContractBookingService — manual odd-20ft consolidation', () => { + function makeService(overrides: { + bookingsRepository?: Partial>; + dataSource?: unknown; + }) { + const bookingsRepository = { + findByIdWithFiles: jest.fn(), + findManualConsolidationCandidates: jest.fn().mockResolvedValue([]), + linkConsolidationPartners: jest.fn().mockResolvedValue(undefined), + ...overrides.bookingsRepository, + }; + + // A transaction that simply runs the callback — enough to assert the + // all-or-nothing contract: whatever throws inside propagates out, and the + // caller observes no link written. + const dataSource = overrides.dataSource ?? { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb({})), + }; + + const service = new ContractBookingService( + { findByIdWithRelations: jest.fn() } as never, + bookingsRepository as never, + {} as never, // bookingPricingService + {} as never, // consolidationService + {} as never, // containerTypesService + {} as never, // ruleEngineService + {} as never, // milestoneService + {} as never, // invoiceService + {} as never, // bookingNotifier + dataSource as never, + {} as never, // trainSchedulingService + {} as never, // bookingBatchService + {} as never, // bookingTransitionService + ); + return { service, bookingsRepository, dataSource }; + } + + const partnerBooking = { + id: 'b-2', + reference: 'BK-2', + contractId: 'c-2', + consolidationPartnerId: null, + } as unknown as Booking; + + const pairDto = { + partnerBookingId: 'b-2', + booking: { scheduledDate: '2026-09-01' }, + partner: { scheduledDate: '2026-09-01' }, + }; + + it('completes both halves and links them', async () => { + const { service, bookingsRepository } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest + .fn() + // partner lookup before the transaction + .mockResolvedValueOnce(partnerBooking) + // the two reloads after it + .mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking) + .mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking), + }, + }); + + // Each half runs the ordinary completion machine — one call per booking, so + // each is priced and invoiced on its own. + const complete = jest + .spyOn(service, 'completeUnderContract') + .mockImplementation( + async (_contractId, bookingId) => + ({ + booking: { id: bookingId } as Booking, + warnings: [], + }) as never, + ); + + const result = await service.completeConsolidatedPair( + 'c-1', + 'b-1', + pairDto as never, + ); + + expect(complete).toHaveBeenCalledTimes(2); + // The partner is completed against ITS OWN contract, not this one. + expect(complete.mock.calls[0][0]).toBe('c-1'); + expect(complete.mock.calls[1][0]).toBe('c-2'); + // Neither half may re-enter the automatic matcher — GL links them here. + expect(complete.mock.calls[0][2]).toMatchObject({ + skipAutoConsolidation: true, + }); + expect(complete.mock.calls[1][2]).toMatchObject({ + skipAutoConsolidation: true, + }); + expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith( + 'b-1', + 'b-2', + ); + expect(result.booking.id).toBe('b-1'); + expect(result.partner.id).toBe('b-2'); + }); + + it('links nothing when the partner half fails (all-or-nothing)', async () => { + const { service, bookingsRepository } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking), + }, + }); + + jest + .spyOn(service, 'completeUnderContract') + .mockImplementationOnce( + async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never, + ) + .mockImplementationOnce(async () => { + throw new Error('no train space for the partner'); + }); + + await expect( + service.completeConsolidatedPair('c-1', 'b-1', pairDto as never), + ).rejects.toThrow('no train space for the partner'); + + // The link is the last write in the transaction — it must never happen when + // a half failed, so the rollback leaves no dangling pairing. + expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled(); + }); + + it('refuses a partner that already shares a wagon', async () => { + const { service } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue({ + ...partnerBooking, + consolidationPartnerId: 'b-9', + }), + }, + }); + + await expect( + service.completeConsolidatedPair('c-1', 'b-1', pairDto as never), + ).rejects.toThrow(/already shares a wagon/i); + }); + + it('refuses to consolidate a booking with itself', async () => { + const { service } = makeService({}); + + await expect( + service.completeConsolidatedPair('c-1', 'b-1', { + ...pairDto, + partnerBookingId: 'b-1', + } as never), + ).rejects.toThrow(/cannot be consolidated with itself/i); + }); + + it('offers only bookings whose own 20ft count is odd', async () => { + // Two odd counts always sum to even, so an odd partner is exactly what fills + // the wagon; an even one would leave the pair partial again. + const rows = [ + { + id: 'odd', + reference: 'BK-ODD', + bookingContainers: [ + { quantity: 3, containerType: { sizeFt: 20 } }, + ], + }, + { + id: 'even', + reference: 'BK-EVEN', + bookingContainers: [ + { quantity: 4, containerType: { sizeFt: 20 } }, + ], + }, + // A bare instance has no cargo yet — GL enters it on the split form, so it + // stays a candidate. + { id: 'bare', reference: 'BK-BARE', bookingContainers: [] }, + ]; + + const { service } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest + .fn() + .mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking), + findManualConsolidationCandidates: jest.fn(async (booking: Booking) => + // Mirror the repository's in-memory odd filter. + rows.filter((row) => { + void booking; + const lines = row.bookingContainers ?? []; + if (lines.length === 0) return true; + const ft20 = lines + .filter((l) => Number(l.containerType?.sizeFt) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + return ft20 % 2 === 1; + }), + ), + }, + }); + + const candidates = await service.listConsolidationCandidates('c-1', 'b-1'); + expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']); + expect(candidates[0].ft20Quantity).toBe(3); + expect(candidates[1].hasCargo).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 3d362d53f..ddd85fc48 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -44,6 +44,7 @@ import { import { ClearanceMilestoneService } from './clearance-milestone.service'; import { isEffectivelyExpired } from './utils/contract-expiry.util'; import { + CompleteConsolidatedPairDto, CreateBookingContainerLineDto, CreateBookingUnderContractDto, } from './dto/create-booking-under-contract.dto'; @@ -62,6 +63,25 @@ export interface CreateBookingUnderContractResult { warnings: string[]; } +/** + * A booking GL may pick as the shared-wagon partner of an odd-20ft customs + * booking. `hasCargo` is false for a bare instance whose containers GL still has + * to enter on the split completion form. + */ +export interface ConsolidationCandidate { + id: string; + reference: string; + contractId: string | null; + companyName: string | null; + status: string; + tradeDirection: string | null; + originYardId: string | null; + destinationYardId: string | null; + scheduledDate: string | null; + ft20Quantity: number; + hasCargo: boolean; +} + /** * Outstanding split remainder of a contract: what was booked in the first split * booking's pre-split snapshot MINUS everything currently booked. Container @@ -598,6 +618,134 @@ export class ContractBookingService { return created; } + /** + * Candidate partners a GL operator may link to an odd-20ft customs booking. + * Manual counterpart to the automatic pairing in {@link consolidateDrawdown} — + * a customs instance is completed by GL, so GL also chooses who shares its + * wagon rather than waiting for the auto-matcher to find an exact complement. + */ + async listConsolidationCandidates( + contractId: string, + bookingId: string, + ): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking || booking.contractId !== contractId) { + throw new NotFoundException(`Booking ${bookingId} not found on this contract`); + } + + const rows = await this.bookingsRepository.findManualConsolidationCandidates( + booking, + ); + return rows.map((row) => { + const lines = row.bookingContainers ?? []; + return { + id: row.id, + reference: row.reference, + contractId: row.contractId ?? null, + companyName: row.company?.name ?? null, + status: row.status, + tradeDirection: row.tradeDirection ?? null, + originYardId: row.originYardId ?? null, + destinationYardId: row.destinationYardId ?? null, + scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null, + ft20Quantity: lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0), + hasCargo: lines.length > 0, + }; + }); + } + + /** + * Complete an odd-20ft customs booking together with the partner booking GL + * picked for its shared wagon. Both halves run the ordinary + * {@link completeUnderContract} machine — same gates, same pricing, same + * per-booking invoice, so each customer still pays only its own shipment — and + * are linked as consolidation partners at the end. + * + * All-or-nothing: the two completions plus the pairing run inside one + * transaction, so a failure on either half leaves neither booking completed + * and no half-linked wagon behind. `runInTransaction` is used rather than a + * manual QueryRunner so the nested services join the same transactional + * context through the shared DataSource. + */ + async completeConsolidatedPair( + contractId: string, + bookingId: string, + dto: CompleteConsolidatedPairDto, + actorPermissions?: unknown, + ): Promise<{ + booking: Booking; + partner: Booking; + warnings: string[]; + }> { + if (dto.partnerBookingId === bookingId) { + throw new BadRequestException( + 'A booking cannot be consolidated with itself.', + ); + } + + const partner = await this.bookingsRepository.findByIdWithFiles( + dto.partnerBookingId, + ); + if (!partner) { + throw new NotFoundException( + `Partner booking ${dto.partnerBookingId} not found`, + ); + } + if (partner.consolidationPartnerId) { + throw new ConflictException( + `Booking ${partner.reference} already shares a wagon with another booking.`, + ); + } + if (!partner.contractId) { + throw new BadRequestException( + `Booking ${partner.reference} is not a contract booking and cannot be completed here.`, + ); + } + + const warnings: string[] = []; + + const { ownId, partnerId } = await this.dataSource.transaction(async () => { + const own = await this.completeUnderContract( + contractId, + bookingId, + { ...dto.booking, skipAutoConsolidation: true }, + // Both halves are completed by the same GL actor that reached this + // endpoint — the customs gate in completeUnderContract re-checks it. + actorPermissions, + ); + warnings.push(...own.warnings); + + const other = await this.completeUnderContract( + partner.contractId as string, + partner.id, + { ...dto.partner, skipAutoConsolidation: true }, + actorPermissions, + ); + warnings.push(...other.warnings); + + // Link the two halves. Written directly (not via pairConsolidation) because + // both bookings have just been completed into their live status here — + // pairConsolidation exists to RESUME bookings parked in + // PENDING_CONSOLIDATION and would overwrite that status. + await this.bookingsRepository.linkConsolidationPartners( + own.booking.id, + other.booking.id, + ); + return { ownId: own.booking.id, partnerId: other.booking.id }; + }); + + // Sequential reads: one connection per transaction context. + const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId); + const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId); + return { + booking: finalBooking!, + partner: finalPartner ?? partner, + warnings, + }; + } + /** * Complete a bare initiated booking after its per-booking clearance is * finalized (CLEARANCE_READY) or operations returned it for changes @@ -826,10 +974,18 @@ export class ContractBookingService { // exactly like a drawdown created with cargo does. The shipment day is // stored first so the pairing event can resume straight into the // operations queue. + // Customs (Path B) instances are exempt from the AUTO-matcher: GL links + // their shared wagon by hand through completeConsolidatedPair, so nothing + // may claim a partner for them behind GL's back. A customs half completed + // as part of a manual pair carries `skipAutoConsolidation`; one completed + // alone still falls through to the automatic gate below, so an odd 20ft + // booking can never proceed on a partial wagon. Non-customs drawdowns are + // unaffected. const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id); if ( withContainers && freightType === 'CONTAINER' && + !dto.skipAutoConsolidation && (await this.consolidationService.needsConsolidationFromBooking(withContainers)) ) { await this.bookingsRepository.update(booking.id, { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index c9db49b24..e9404f8e1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -76,7 +76,10 @@ import { import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; import { RenewContractDto } from './dto/renew-contract.dto'; -import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +import { + CompleteConsolidatedPairDto, + CreateBookingUnderContractDto, +} from './dto/create-booking-under-contract.dto'; import { CreateBookingRequestDto, ReviewBookingRequestDto, @@ -1152,6 +1155,41 @@ export class ContractsController { // Customs (Path B) instances may only be completed by GL Ethiopia — the // service checks the actor's contracts:create_booking permission. return this.contractBookingService.completeUnderContract( + id, + bookingId, + // skipAutoConsolidation is internal to the manual pair-completion path; a + // client must never suppress the wagon gate on a lone booking. + { ...dto, skipAutoConsolidation: false }, + user, + ); + } + + @Get(':id/bookings/:bookingId/consolidation-candidates') + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ + summary: + 'Bookings GL may link to this odd-20ft customs booking as its shared-wagon partner (same route and direction, customs, odd 20ft, unpaired).', + }) + listConsolidationCandidates( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + ) { + return this.contractBookingService.listConsolidationCandidates(id, bookingId); + } + + @Post(':id/bookings/:bookingId/complete-consolidated') + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ + summary: + 'Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.', + }) + completeConsolidatedPair( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: CompleteConsolidatedPairDto, + @CurrentUser() user: TCurrentUser & { sub?: string }, + ) { + return this.contractBookingService.completeConsolidatedPair( id, bookingId, dto, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 8c1bf763d..f50ca9d4d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsArray, @@ -219,4 +219,46 @@ export class CreateBookingUnderContractDto { @IsOptional() @IsString() notes?: string; + + /** + * Internal: set by the manual GL pair-completion path, never by a client. + * Suppresses the automatic wagon-consolidation gate for this completion + * because the caller links the shared wagon itself. Excluded from the public + * schema so a client cannot set it to bypass the gate on a lone booking. + */ + @ApiHideProperty() + @IsOptional() + @IsBoolean() + skipAutoConsolidation?: boolean; +} + +/** + * Complete an odd-20ft customs booking together with the partner booking GL + * picked to share its wagon. Each half carries its own full completion payload — + * the two bookings stay separately priced and separately invoiced, they only + * share the wagon. + */ +export class CompleteConsolidatedPairDto { + @ApiProperty({ + format: 'uuid', + description: 'The booking chosen to share this booking’s wagon.', + }) + @IsUUID() + partnerBookingId!: string; + + @ApiProperty({ + type: CreateBookingUnderContractDto, + description: 'Completion payload for the booking in the URL.', + }) + @ValidateNested() + @Type(() => CreateBookingUnderContractDto) + booking!: CreateBookingUnderContractDto; + + @ApiProperty({ + type: CreateBookingUnderContractDto, + description: 'Completion payload for the partner booking.', + }) + @ValidateNested() + @Type(() => CreateBookingUnderContractDto) + partner!: CreateBookingUnderContractDto; } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index a50028e90..5b7de12d5 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -38,6 +38,7 @@ export function BookingActionsMenu({ reference: row.reference, schedulingStatus: row.schedulingStatus, customsClearingEnabled: row.customsClearingEnabled, + consolidationPartnerId: row.consolidationPartnerId, }; const flow = useBookingActionDialog(row.id, context); @@ -92,7 +93,13 @@ export function BookingActionsMenu({ ); })} - + ); } @@ -149,7 +156,13 @@ export function BookingActionsMenu({ - + ); } @@ -158,10 +171,14 @@ function ActionDialog({ flow, pendingAction, onSuppressRowClick, + consolidationPartnerId, + consolidationPartnerReference, }: { flow: ReturnType; pendingAction: ReturnType["pendingAction"]; onSuppressRowClick?: () => void; + consolidationPartnerId?: string | null; + consolidationPartnerReference?: string | null; }) { return ( ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index 6b8e4b650..246704aca 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; +import { Link2 } from "lucide-react"; import { + Alert, Modal, Group, Stack, @@ -37,6 +39,12 @@ interface BookingConfirmDialogProps { isPending: boolean; confirmDisabled?: boolean; extra?: ReactNode; + /** + * Reference of the booking sharing this one's wagon. When set, the dialog + * warns that the decision lands on BOTH bookings — staff must not think they + * are acting on one. + */ + pairedWithReference?: string | null; } export function BookingConfirmDialog({ @@ -52,6 +60,7 @@ export function BookingConfirmDialog({ isPending, confirmDisabled = false, extra, + pairedWithReference = null, }: BookingConfirmDialogProps) { if (!action || !action.confirmTitle) return null; @@ -125,6 +134,21 @@ export function BookingConfirmDialog({ {action.confirmDescription} )} + {pairedWithReference && ( + } + > + + This applies to {pairedWithReference} as well — + the two bookings share a wagon and are decided together. If either + fails, neither changes. + + + )} {/* Body */} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts index be7111745..24398bdf9 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts @@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean { return Number.isInteger(days) && days >= 1 && days <= 365; } +/** + * Decisions that must be applied to BOTH halves of a consolidated pair. The two + * bookings share one wagon: accepting one alone would put half a wagon into the + * approval chain, and cancelling one alone would strand the other on a wagon it + * can no longer fill. + */ +const PAIRED_DECISIONS = { + accept: "accept", + cancel: "cancel", + operationAccept: "operationAccept", + requestChanges: "requestChanges", +} as const; + export function useBookingActionDialog( bookingId: string, context: BookingActionContext, @@ -52,6 +65,30 @@ export function useBookingActionDialog( const onSuccess = () => closeDialog(); + // A booking on a shared wagon routes the four pairable decisions through the + // paired endpoint, which applies them to both halves all-or-nothing. Every + // other action stays per booking. + const pairedDecision = + PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS]; + if (context.consolidationPartnerId && pairedDecision) { + if (pairedDecision === "accept") { + const days = Number(inputValue.trim()); + if (!Number.isInteger(days) || days < 1 || days > 365) return; + mutations.pairedDecision.mutate( + { decision: "accept", validityDays: days }, + { onSuccess }, + ); + return; + } + mutations.pairedDecision.mutate( + pairedDecision === "cancel" + ? { decision: "cancel", reason: inputValue.trim() } + : { decision: pairedDecision, note: inputValue.trim() }, + { onSuccess }, + ); + return; + } + switch (pendingAction.id) { case "accept": { const days = Number(inputValue.trim()); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 67e84a09f..ca55338cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ActionIcon, Alert, + Badge, Box, Button, Center, @@ -40,6 +41,7 @@ import { FileText, FileUp, Flame, + Link2, MapPin, Package, Receipt, @@ -57,7 +59,10 @@ import { import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; -import { contractsService } from "@/services/contracts.service"; +import { + contractsService, + type ConsolidationCandidate, +} from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; import { useContractCapacity, @@ -80,6 +85,18 @@ import { StepHeader, StepLabel, } from "./gl-booking-form/form-ui"; +import { + ConsolidationPartnerPanel, + emptyPartnerLine, +} from "./gl-booking-form/ConsolidationPartnerPanel"; +import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; + +/** + * Container sizes offered on the parent-booking panel. Fixed rather than taken + * from this contract's scope: the parent booking is a different customer on a + * different contract, so its sizes are its own. + */ +const PARTNER_SIZES = ["20ft", "40ft"]; /** All booking-window times are communicated in East Africa Time. */ const EAT_TZ = "Africa/Addis_Ababa"; @@ -240,6 +257,14 @@ export default function GlCreateBookingForm() { enabled: Boolean(copyFromParam), }); + // The booking being completed — used to name the customer on the price + // confirmation when a second booking's price is shown beside it. + const { data: completeBooking } = useQuery({ + queryKey: ["gl-complete-booking", completeBookingId], + queryFn: () => bookingsService.getById(completeBookingId!), + enabled: Boolean(completeBookingId), + }); + // Same window-gating the customer sees: booking is only allowed while a // window is OPEN for one of the contract's routes. Intercity contracts are // never window-gated — the shipment rides a passing train staff pick later. @@ -290,6 +315,18 @@ export default function GlCreateBookingForm() { const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); + // ── Odd-20ft shared wagon (customs / Path B) ────────────────────────────── + // An odd 20ft total leaves one container unpaired. On a customs contract GL + // resolves that here by linking a second booking that is also odd — two odd + // counts always sum to even — completing both together onto the shared wagon. + const [consolidateOdd, setConsolidateOdd] = useState(false); + // Set once GL flips the toggle by hand, so the auto-on effect below never + // re-opens a panel GL deliberately closed. + const consolidateTouchedRef = useRef(false); + const [partnerPickerOpen, setPartnerPickerOpen] = useState(false); + const [partner, setPartner] = useState(null); + const [partnerLines, setPartnerLines] = useState([]); + const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); const seededRef = useRef(false); const returnSeededRef = useRef(false); @@ -834,6 +871,48 @@ export default function GlCreateBookingForm() { }, [isContainer, containerLines]); const hasOdd20ft = ft20Total % 2 === 1; + // Only a customs (Path B) instance being COMPLETED by GL can use the shared + // wagon: it is GL, not the customer, who links the two bookings. Anything else + // keeps the historical hard block on odd 20ft. + const oddConsolidationAvailable = Boolean( + completeBookingId && isContainer && contract?.customsClearingEnabled, + ); + + // Auto-on: entering an odd 20ft total opens the consolidation panel by itself, + // once. GL can still switch it off — then odd is blocked exactly as before. + useEffect(() => { + if (!oddConsolidationAvailable) return; + if (consolidateTouchedRef.current) return; + if (hasOdd20ft) setConsolidateOdd(true); + }, [oddConsolidationAvailable, hasOdd20ft]); + + // Clear the partner as soon as the panel closes or stops applying, so a + // leftover selection can never ride along into a plain single-booking submit. + useEffect(() => { + if (consolidateOdd && oddConsolidationAvailable) return; + setPartner(null); + setPartnerLines([]); + setPartnerCargoDescription(""); + }, [consolidateOdd, oddConsolidationAvailable]); + + const consolidationActive = + oddConsolidationAvailable && consolidateOdd && hasOdd20ft; + + // Once a parent booking is linked, each booking's cargo is entered under its + // own labelled heading so it is clear which containers belong to whom. + const splitView = Boolean(consolidationActive && partner); + + const candidatesQuery = useQuery({ + queryKey: ["consolidation-candidates", id, completeBookingId], + queryFn: () => + contractsService.listConsolidationCandidates( + id ?? "", + completeBookingId ?? "", + ), + enabled: + partnerPickerOpen && Boolean(id) && Boolean(completeBookingId), + }); + const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; const bulkErrors = useMemo(() => { @@ -886,7 +965,64 @@ export default function GlCreateBookingForm() { !cargoDescriptionError : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError; + // The unpaired 20ft container is resolved by the shared wagon, so with an + // active consolidation an odd total stops being a blocker; without one it + // blocks exactly as before. + const oddBlocksSubmit = hasOdd20ft && !consolidationActive; + + // Partner side: a linked partner must be picked, carry an odd 20ft count of + // its own (odd + odd = even fills the wagon) and have complete unit details. + const partnerFt20Total = useMemo(() => { + if (!consolidationActive) return 0; + return partnerLines + .filter((l) => parseInt(l.containerSize, 10) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + }, [consolidationActive, partnerLines]); + + const partnerError = useMemo(() => { + if (!consolidationActive) return undefined; + if (!partner) return "Select the booking that shares this wagon."; + const totalQty = partnerLines.reduce( + (sum, l) => sum + Math.max(0, Number(l.quantity) || 0), + 0, + ); + if (totalQty < 1) { + return `Enter the containers for ${partner.reference}.`; + } + if (partnerFt20Total % 2 === 0) { + return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`; + } + const incomplete = partnerLines.some((line) => { + const qty = Number(line.quantity || 0); + return qty >= 1 && line.units.length < qty; + }); + if (incomplete) { + return `Enter the container details for all of ${partner.reference}'s containers.`; + } + const badUnit = partnerLines.some((line) => + line.units.some( + (u) => + !ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) || + !(Number(u.vgmTons) > 0), + ), + ); + if (badUnit) { + return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`; + } + if (!partnerCargoDescription.trim()) { + return `Describe the cargo carried in ${partner.reference}'s containers.`; + } + return undefined; + }, [ + consolidationActive, + partner, + partnerLines, + partnerFt20Total, + partnerCargoDescription, + ]); + + const formValid = + cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError; /** The create-booking DTO from the current form state — shared by the * authoritative price preview and the actual submit so what GL confirms is @@ -953,6 +1089,44 @@ export default function GlCreateBookingForm() { return payload; }; + /** + * Completion DTO for the partner half of a shared wagon. Route, day and train + * are deliberately copied from THIS booking: the two bookings ride the same + * wagon, so they must ride the same train on the same day. Only the cargo and + * the billing currency belong to the partner. + */ + const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!partner || !consolidationActive) return null; + + const payload: Freight.CreateBookingUnderContractDto = { + paymentCurrency, + ...(scheduledDate + ? { scheduledDate: new Date(scheduledDate).toISOString() } + : {}), + ...(trainScheduleId ? { trainScheduleId } : {}), + ...(partnerCargoDescription.trim() + ? { cargoFreeText: partnerCargoDescription.trim() } + : {}), + containers: partnerLines + .filter((l) => Number(l.quantity) >= 1) + .map((l) => ({ + containerSize: l.containerSize, + quantity: Number(l.quantity), + hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, + reeferQuantity: Number(l.reeferQuantity || 0) || undefined, + units: l.units.map((u) => ({ + containerNumber: u.containerNumber.trim().toUpperCase(), + ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + vgmTons: Number(u.vgmTons) || 0, + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + })), + })), + }; + + return payload; + }; + // Authoritative price preview (same pricing pass the booking persists at // create): rail freight + first/last mile + overweight + every surcharge, // plus the hard-block checks (20ft pairing, max capacity, container numbers @@ -964,6 +1138,22 @@ export default function GlCreateBookingForm() { }); const validation = validateShipmentMutation.data ?? null; + // The partner is priced against ITS OWN contract, so the two totals shown in + // the confirm modal are each customer's real bill — nobody pays for the other. + const validatePartnerMutation = useMutation({ + mutationFn: (input: { + contractId: string; + bookingId: string; + dto: Freight.CreateBookingUnderContractDto; + }) => + contractsService.validateShipment( + input.contractId, + input.dto, + input.bookingId, + ), + }); + const partnerValidation = validatePartnerMutation.data ?? null; + const serverTotal = useMemo(() => { const items = validation?.lineItems; if (!items?.length) return null; @@ -1010,8 +1200,52 @@ export default function GlCreateBookingForm() { }; }, [serverTotal, priceTotal, overweightSurchargeAmount]); + const partnerTotal = useMemo(() => { + const items = partnerValidation?.lineItems; + if (!items?.length) return null; + return { + currency: partnerValidation?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [partnerValidation]); + + // The partner half must clear the same hard blocks as this one — the pair is + // booked all-or-nothing, so a block on either side blocks both. + const partnerBlockers = useMemo(() => { + if (!consolidationActive || !partnerValidation) return []; + return [ + ...(partnerValidation.pairingErrors ?? []), + ...(partnerValidation.capacityErrors ?? []), + ...(partnerValidation.containerClashErrors ?? []), + ...(partnerValidation.spaceErrors ?? []), + ]; + }, [consolidationActive, partnerValidation]); + + const completePairMutation = useMutation({ + mutationFn: (input: { + payload: Freight.CreateBookingUnderContractDto; + partnerPayload: Freight.CreateBookingUnderContractDto; + partnerBookingId: string; + }) => + contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", { + partnerBookingId: input.partnerBookingId, + booking: input.payload, + partner: input.partnerPayload, + }), + }); + const submitPending = - mutations.createBooking.isPending || mutations.completeBooking.isPending; + mutations.createBooking.isPending || + mutations.completeBooking.isPending || + completePairMutation.isPending; // Block confirm until the authoritative server price is in hand — the client // estimate is display-only; booking on it would confirm an un-validated, @@ -1023,7 +1257,13 @@ export default function GlCreateBookingForm() { capacityErrors.length > 0 || containerClashErrors.length > 0 || spaceErrors.length > 0 || - !serverTotal; + !serverTotal || + // Same bar for the shared-wagon partner: its authoritative price must be in + // hand and its own hard blocks clear before either booking is confirmed. + (consolidationActive && + (validatePartnerMutation.isPending || + !partnerTotal || + partnerBlockers.length > 0)); const openPriceModal = () => { // Surface the per-field errors (portal-parity validation) instead of @@ -1039,6 +1279,15 @@ export default function GlCreateBookingForm() { validateShipmentMutation.reset(); validateShipmentMutation.mutate(payload); } + validatePartnerMutation.reset(); + const partnerPayload = buildPartnerPayload(); + if (partnerPayload && partner?.contractId) { + validatePartnerMutation.mutate({ + contractId: partner.contractId, + bookingId: partner.id, + dto: partnerPayload, + }); + } }; const handleSubmit = () => { @@ -1054,6 +1303,25 @@ export default function GlCreateBookingForm() { const payload = buildPayload(); if (!payload) return; + // Shared wagon: both halves complete together, all-or-nothing on the server. + if (consolidationActive && partner && completeBookingId) { + // A hard block on the partner's own price preview blocks the pair. + if (partnerBlockers.length > 0) return; + const partnerPayload = buildPartnerPayload(); + if (!partnerPayload) return; + completePairMutation.mutate( + { + payload, + partnerPayload, + partnerBookingId: partner.id, + }, + { + onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`), + }, + ); + return; + } + if (completeBookingId) { // Completion mode: cargo + day land on the already-cleared instance — // the request was linked and accepted at submission time. @@ -1347,6 +1615,18 @@ export default function GlCreateBookingForm() { maxRows={4} styles={fieldStyles} /> + {/* With a parent booking linked, each booking's containers are + entered in its own labelled section, one after the other. */} + {splitView ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? "—"} + + + ) : null} {containerLines.length === 0 ? ( This contract has no container sizes in scope. @@ -1526,7 +1806,71 @@ export default function GlCreateBookingForm() { )) )} - {hasOdd20ft ? ( + {hasOdd20ft && oddConsolidationAvailable ? ( + } + title={`Odd number of 20ft containers (${ft20Total})`} + > + + + 20ft containers travel two per wagon, so one container here + is unpaired. On a customs booking you can pair it with + another customer's odd booking and complete both onto the + shared wagon — each booking is still priced and invoiced + separately. + + { + consolidateTouchedRef.current = true; + setConsolidateOdd(e.currentTarget.checked); + }} + /> + {consolidateOdd ? ( + + + {partner ? ( + + ) : null} + + ) : ( + + With sharing off, book an even number of 20ft containers + — add one more or remove one (e.g. {ft20Total + 1} or{" "} + {ft20Total - 1} instead of {ft20Total}). + + )} + + + ) : hasOdd20ft ? ( ) : null} + + {splitView && partner ? ( + <> + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + Parent booking — ships on the same day and train, billed to + its own customer. + + + + ) : null} ) : ( @@ -1806,12 +2178,31 @@ export default function GlCreateBookingForm() { > Fix the highlighted fields before reviewing the price. + ) : partnerError ? ( + // The review button is disabled while the parent booking is + // incomplete, so the click that would reveal the errors never + // lands — say what is outstanding without waiting for it. + } + mb="sm" + > + {partnerError} + ) : null} {/* Mantine tooltips get no pointer events from a disabled button, so the wrapper carries the hover target. */} @@ -1821,9 +2212,11 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} onClick={openPriceModal} - // Same hard block the customer portal applies at review time — - // an unpaired 20ft can never be planned onto a wagon. - disabled={hasOdd20ft} + // An unpaired 20ft can never be planned onto a wagon — unless + // a parent booking is linked to share it, which is what + // oddBlocksSubmit accounts for. The parent's own cargo must be + // complete too, or there is nothing to price. + disabled={oddBlocksSubmit || Boolean(partnerError)} > Review price & book @@ -1833,6 +2226,24 @@ export default function GlCreateBookingForm() { + setPartnerPickerOpen(false)} + candidates={candidatesQuery.data ?? []} + isLoading={candidatesQuery.isLoading} + isError={candidatesQuery.isError} + onSelect={(candidate) => { + setPartner(candidate); + // Seed a 20ft and a 40ft line. The parent booking sits on its OWN + // contract, whose size scope need not match this one's, so the panel + // offers both sizes rather than mirroring this contract's scope; a + // size the parent does not ship is simply left at 0. + setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine)); + setPartnerCargoDescription(""); + setPartnerPickerOpen(false); + }} + /> + { @@ -1984,6 +2395,18 @@ export default function GlCreateBookingForm() { )} + {/* Whose bill this is. Only worth naming when a second booking is + on screen — on a lone booking there is nothing to confuse it with. */} + {consolidationActive && partner ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? contract.company?.name ?? "—"} + + + ) : null} {displayTotal.lines.map((line, i) => ( @@ -2028,6 +2451,123 @@ export default function GlCreateBookingForm() { + {consolidationActive && partner ? ( + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + {validatePartnerMutation.isPending ? ( + + + + Pricing the partner booking… + + + ) : partnerBlockers.length > 0 ? ( + } + title={`Cannot book ${partner.reference}`} + > + + {partnerBlockers.map((msg, i) => ( + + {msg} + + ))} + + Both bookings are confirmed together, so this must be + fixed before either can be booked. + + + + ) : partnerTotal ? ( + <> + + {partnerTotal.lines.map((line, i) => ( + + + + {line.label} + + + {line.quantity.toLocaleString()} ×{" "} + {line.unitPrice.toLocaleString()}{" "} + {partnerTotal.currency} ·{" "} + {formatRateUnit(line.unit)} + + + + {line.amount.toLocaleString()}{" "} + {partnerTotal.currency} + + + ))} + + + + + Total + + + {partnerTotal.total.toLocaleString()}{" "} + + {partnerTotal.currency} + + + + + ) : ( + + No price yet for the partner booking. + + )} + + ) : null} + + {consolidationActive && partner ? ( + } + > + + These two bookings share one wagon but stay separate: each is + invoiced to its own customer and paid separately. Confirming + books both together — if either fails, neither is booked. + + + ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx new file mode 100644 index 000000000..e52cce097 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -0,0 +1,255 @@ +import { type KeyboardEvent } from "react"; +import { + Box, + Checkbox, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; + +/** + * Container editor for the PARTNER half of a shared wagon. Deliberately a + * reduced version of the main form's editor: the partner contributes only cargo + * — route, shipment day and train are inherited from the booking it shares the + * wagon with, and hazardous/reefer/return counts are derived from the per-unit + * ticks rather than typed line totals. + */ + +export interface PartnerUnitDraft { + containerNumber: string; + sealNumber: string; + vgmTons: string; + isHazardous: boolean; + isReefer: boolean; + isReturn: boolean; +} + +export interface PartnerLineDraft { + containerSize: string; + quantity: string; + hazardousQuantity: string; + reeferQuantity: string; + returnQuantity: string; + units: PartnerUnitDraft[]; +} + +export function emptyPartnerUnit(): PartnerUnitDraft { + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, + }; +} + +export function emptyPartnerLine(size: string): PartnerLineDraft { + return { + containerSize: size, + quantity: "0", + hazardousQuantity: "0", + reeferQuantity: "0", + returnQuantity: "0", + units: [], + }; +} + +/** Quantities are magnitudes — swallow the minus key before it reaches the field. */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + +/** Grow or shrink a line's unit rows to match its quantity. */ +function syncUnits(line: PartnerLineDraft, quantity: number): PartnerLineDraft { + const target = Math.max(0, Math.floor(quantity) || 0); + const units = [...line.units]; + while (units.length < target) units.push(emptyPartnerUnit()); + units.length = target; + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; +} + +interface Props { + lines: PartnerLineDraft[]; + onLinesChange: (lines: PartnerLineDraft[]) => void; + cargoDescription: string; + onCargoDescriptionChange: (value: string) => void; + /** Whether per-container hazardous / refrigerated ticks apply. */ + showHazardous: boolean; + showReefer: boolean; + /** Surface field errors only after the operator tried to continue. */ + showErrors: boolean; + error?: string; +} + +export function ConsolidationPartnerPanel({ + lines, + onLinesChange, + cargoDescription, + onCargoDescriptionChange, + showHazardous, + showReefer, + showErrors, + error, +}: Props) { + const patchLine = (index: number, patch: Partial) => { + onLinesChange( + lines.map((line, i) => (i === index ? { ...line, ...patch } : line)), + ); + }; + + const patchUnit = ( + lineIndex: number, + unitIndex: number, + patch: Partial, + ) => { + onLinesChange( + lines.map((line, i) => { + if (i !== lineIndex) return line; + const units = line.units.map((unit, u) => + u === unitIndex ? { ...unit, ...patch } : unit, + ); + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; + }), + ); + }; + + return ( + + {error && showErrors ? ( + + {error} + + ) : null} + + {lines.map((line, lineIdx) => ( + + + {line.containerSize} containers + + + patchLine(lineIdx, { quantity: e.currentTarget.value })} + // Sync off the typed value, not the captured `line` — that snapshot + // still holds the pre-edit quantity and would write it back. + onBlur={(e) => { + const typed = e.currentTarget.value; + patchLine(lineIdx, { + ...syncUnits({ ...line, quantity: typed }, Number(typed || 0)), + quantity: typed, + }); + }} + mb={12} + /> + + {line.units.map((unit, unitIdx) => ( + + + Container {unitIdx + 1} + + + + patchUnit(lineIdx, unitIdx, { + containerNumber: e.currentTarget.value.toUpperCase(), + }) + } + /> + + patchUnit(lineIdx, unitIdx, { + sealNumber: e.currentTarget.value, + }) + } + /> + 0) + ? "Required." + : undefined + } + onChange={(e) => + patchUnit(lineIdx, unitIdx, { vgmTons: e.currentTarget.value }) + } + /> + + {showHazardous || showReefer ? ( + + {showHazardous ? ( + + patchUnit(lineIdx, unitIdx, { + isHazardous: e.currentTarget.checked, + }) + } + /> + ) : null} + {showReefer ? ( + + patchUnit(lineIdx, unitIdx, { + isReefer: e.currentTarget.checked, + }) + } + /> + ) : null} + + ) : null} + + ))} + + ))} + + onCargoDescriptionChange(e.currentTarget.value)} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx new file mode 100644 index 000000000..c20c08994 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx @@ -0,0 +1,137 @@ +import { + Alert, + Badge, + Box, + Button, + Center, + Group, + Loader, + Modal, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { AlertCircle, Link2 } from "lucide-react"; + +import type { ConsolidationCandidate } from "@/services/contracts.service"; + +/** + * Picker for the booking that shares this booking's wagon. The server has + * already narrowed the list to bookings that can legally pair — same route and + * direction, customs clearing, an odd 20ft count of their own and not already + * linked to someone else — so every row here is a valid choice. + */ +interface Props { + opened: boolean; + onClose: () => void; + candidates: ConsolidationCandidate[]; + isLoading: boolean; + isError: boolean; + onSelect: (candidate: ConsolidationCandidate) => void; +} + +export function ConsolidationPartnerPicker({ + opened, + onClose, + candidates, + isLoading, + isError, + onSelect, +}: Props) { + return ( + + + + + + + Pick the parent booking + + + Customs bookings on the same route that also carry an odd number of + 20ft containers. + + + + } + > + {isLoading ? ( +
+ +
+ ) : isError ? ( + } + > + Could not load the candidate bookings. Close this and try again. + + ) : candidates.length === 0 ? ( + } + title="No booking available to share this wagon" + > + + No other customs booking on this route currently carries an odd + number of 20ft containers. Either wait for one, or switch the + shared-wagon option off and book an even number of 20ft containers. + + + ) : ( + + {candidates.map((candidate) => ( + + + + + + {candidate.reference} + + + {candidate.status.replaceAll("_", " ")} + + + + {candidate.companyName ?? "—"} + {candidate.tradeDirection + ? ` · ${candidate.tradeDirection}` + : ""} + {" · "} + {candidate.hasCargo + ? `${candidate.ft20Quantity} × 20ft` + : "cargo not entered yet"} + + + + + + ))} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d3165f696..1415ad37b 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -191,6 +191,8 @@ export const URL_CONSTANTS = { BY_ID: (id: string) => `/bookings/${id}`, QUEUE: (queue: string) => `/bookings/queues/${queue}`, STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`, + // Consolidated pair: one staff decision applied to both halves at once. + PAIRED_DECISION: (id: string) => `/bookings/${id}/paired-decision`, STAFF_REQUEST_CHANGES: (id: string) => `/bookings/${id}/staff/request-changes`, STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`, @@ -317,6 +319,12 @@ export const URL_CONSTANTS = { AWAITING_SHIPMENT: "/contracts/awaiting-shipment", BOOKINGS_COMPLETE: (id: string, bookingId: string) => `/contracts/${id}/bookings/${bookingId}/complete`, + // Odd-20ft shared-wagon consolidation (customs/Path B): candidates GL may + // link, and the all-or-nothing completion of both halves together. + CONSOLIDATION_CANDIDATES: (id: string, bookingId: string) => + `/contracts/${id}/bookings/${bookingId}/consolidation-candidates`, + BOOKINGS_COMPLETE_CONSOLIDATED: (id: string, bookingId: string) => + `/contracts/${id}/bookings/${bookingId}/complete-consolidated`, VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`, CAPACITY: (id: string) => `/contracts/${id}/capacity`, // Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking. diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index 5600f6cfb..eba31130c 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -59,6 +59,9 @@ export type BookingActionContext = Pick< | "reference" | "schedulingStatus" | "customsClearingEnabled" + // Set when this booking shares a wagon: the pairable staff decisions then + // apply to both halves at once rather than to this booking alone. + | "consolidationPartnerId" >; const ALLOCATABLE_SCHEDULING_STATUSES = new Set([ diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts index 2fce8aa8f..64e993207 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts @@ -121,7 +121,38 @@ export function useBookingMutations(bookingId: string) { onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")), }); + /** + * One staff decision applied to both halves of a consolidated pair. Both + * bookings are invalidated on success so whichever tab is open reflects the + * new state immediately. + */ + const pairedDecision = useMutation({ + mutationFn: (payload: { + decision: "accept" | "cancel" | "operationAccept" | "requestChanges"; + reason?: string; + note?: string; + validityDays?: number; + }) => { + const { decision, ...options } = payload; + return bookingsService.pairedDecision(bookingId, decision, options); + }, + onSuccess: (data) => { + toast.success("Applied to both bookings on the shared wagon"); + void invalidateBookingDetail(qc, data.booking.id); + void invalidateBookingDetail(qc, data.partner.id); + }, + onError: (error) => { + toast.error( + parseApiError(error, "Failed to apply the decision to both bookings"), + ); + // Nothing should have committed (the server runs both halves in one + // transaction), but refetch so the UI never shows a stale guess. + void invalidateBookingDetail(qc, bookingId); + }, + }); + const isPending = + pairedDecision.isPending || staffAccept.isPending || requestChanges.isPending || staffReject.isPending || @@ -134,6 +165,7 @@ export function useBookingMutations(bookingId: string) { cancel.isPending; return { + pairedDecision, staffAccept, requestChanges, staffReject, diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 62c26276a..2444899dd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -9,6 +9,7 @@ import { FolderOpen, Layers, LayoutGrid, + Link2, Milestone, MoreHorizontal, Package, @@ -79,14 +80,50 @@ export default function BookingRequestDetailPage() { const [searchParams, setSearchParams] = useSearchParams(); // Deep-link from a warehouse fee invoice → this booking's warehouse section. useScrollToHash(); + + // Consolidated pair: `?booking=` swaps the WHOLE page over to the + // other half of the shared wagon. Everything below — KPIs, stepper, the + // overview/orders/documents/trucks sub-tabs, the action toolbar — then reads + // from the selected booking, so each half gets its own complete detail page + // under a top-level tab. The URL id stays put so Back still works. + const selectedId = searchParams.get("booking") || id; const { data: booking, isLoading, isError, refetch, isFetching, - } = useBookingDetail(id); - const mutations = useBookingMutations(id ?? ""); + } = useBookingDetail(selectedId); + const mutations = useBookingMutations(selectedId ?? ""); + + // The pair is discovered from whichever half is on screen: each booking + // carries a reference to the other. + const routeBookingId = id ?? ""; + const partnerId = booking?.consolidationPartnerId ?? null; + const isPaired = Boolean(partnerId); + const viewingPartner = selectedId !== routeBookingId; + // Tab identities: the booking named by the URL is always the first tab, the + // other half the second — regardless of which one is currently displayed. + const firstTabId = routeBookingId; + const secondTabId = viewingPartner ? selectedId : partnerId; + + // Only for the tab label (reference + customer) — the displayed half is + // loaded above. Skipped entirely when the booking is not part of a pair. + const { data: otherBooking } = useBookingDetail( + secondTabId && secondTabId !== selectedId ? secondTabId : undefined, + ); + const firstTabBooking = viewingPartner ? otherBooking : booking; + const secondTabBooking = viewingPartner ? booking : otherBooking; + + const selectBooking = (bookingId: string) => { + const next = new URLSearchParams(searchParams); + if (bookingId === routeBookingId) next.delete("booking"); + else next.set("booking", bookingId); + // Switching booking resets the sub-tab: the other half has its own content + // and may not even have the tab that was open (e.g. Orders). + next.delete("tab"); + setSearchParams(next, { replace: true }); + }; if (isLoading) { return ( @@ -349,6 +386,48 @@ export default function BookingRequestDetailPage() { /> + {/* Consolidated pair: one tab per booking, switching the ENTIRE page + below. The overview/orders/documents/trucks tabs further down are + sub-tabs of whichever booking is selected here. */} + {isPaired && secondTabId ? ( + value && selectBooking(value)} + variant="pills" + radius="md" + > + + }> + + + {firstTabBooking?.reference ?? "Booking"} + + + {firstTabBooking?.company?.name ?? "—"} + + + + }> + + + {secondTabBooking?.reference ?? "Partner booking"} + + + {secondTabBooking?.company?.name ?? "—"} + + + + + + ) : null} + + {isPaired ? ( + + These two bookings share one wagon. Accepting or cancelling applies + to both; each is invoiced and paid separately. + + ) : null} + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 8c2a3a912..ebdba3e11 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -15,6 +15,7 @@ import { CheckCircle2, Clock, LayoutList, + Link2, Package, Plus, RefreshCw, @@ -200,10 +201,28 @@ export default function BookingRequestsPage() { // Search is applied server-side (via the `search` filter param) — no // client-side filtering here. - const rows = useMemo( - () => (data?.items ?? []).map(toBookingListRow), - [data?.items], - ); + const rows = useMemo(() => { + const mapped = (data?.items ?? []).map(toBookingListRow); + // Consolidated pairs share one wagon and are decided together, so they show + // as ONE row. Keep the half that appears first in the current sort and hang + // the other on it as `pairedWith`; the row renders both bookings' details + // and opens the detail page, where each half gets its own tab. + const byId = new Map(mapped.map((row) => [row.id, row])); + const absorbed = new Set(); + const merged: BookingListRow[] = []; + for (const row of mapped) { + if (absorbed.has(row.id)) continue; + const partnerId = row.consolidationPartnerId; + const partner = partnerId ? byId.get(partnerId) : undefined; + if (partner && !absorbed.has(partner.id)) { + absorbed.add(partner.id); + merged.push({ ...row, pairedWith: partner }); + continue; + } + merged.push(row); + } + return merged; + }, [data?.items]); const total = data?.total ?? 0; const hasSearch = controls.searchText.trim().length > 0; @@ -321,6 +340,22 @@ export default function BookingRequestsPage() { ) : null}

+ {/* Shared wagon: the second booking rides in the same row, so the + operator sees both customers before opening the pair. */} + {b.pairedWith ? ( +
+
+ +

+ {b.pairedWith.reference} +

+
+

+ + {b.pairedWith.customerLabel} +

+
+ ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index dead9c520..9fa24924d 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -323,6 +323,26 @@ export const bookingsService = { cancel: (id: string, reason: string) => postBooking(B.CANCEL(id), { reason }), + /** + * Apply one staff decision to BOTH halves of a consolidated pair. The two + * bookings share a wagon, so they advance or cancel together — all-or-nothing + * on the server. Each half keeps its own invoice and payment. + */ + pairedDecision: async ( + id: string, + decision: "accept" | "cancel" | "operationAccept" | "requestChanges", + options: { reason?: string; note?: string; validityDays?: number } = {}, + ): Promise<{ booking: BookingDetail; partner: BookingDetail }> => { + const response = await client.post(B.PAIRED_DECISION(id), { + decision, + ...options, + }); + return unwrap(response.data) as { + booking: BookingDetail; + partner: BookingDetail; + }; + }, + create: async (payload: Record): Promise => { const response = await client.post<{ booking: BookingDetail } | BookingDetail>( B.BASE, diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 01dbadac7..f06f2fb61 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -71,6 +71,32 @@ export interface ShipmentValidation { totalAmount?: number; } +/** + * A booking GL may pick as the shared-wagon partner of an odd-20ft customs + * booking. `hasCargo` is false for a bare instance whose containers GL still + * enters on the split completion form. + */ +export interface ConsolidationCandidate { + id: string; + reference: string; + contractId: string | null; + companyName: string | null; + status: string; + tradeDirection: string | null; + originYardId: string | null; + destinationYardId: string | null; + scheduledDate: string | null; + ft20Quantity: number; + hasCargo: boolean; +} + +/** Both halves of a shared-wagon completion, each with its own full payload. */ +export interface CompleteConsolidatedPairPayload { + partnerBookingId: string; + booking: Freight.CreateBookingUnderContractDto; + partner: Freight.CreateBookingUnderContractDto; +} + export interface ContractListSummaryMetrics { inQueue: number; needsAction: number; @@ -674,6 +700,46 @@ export const contractsService = { }; }, + /** + * Bookings GL may link to an odd-20ft customs booking as its shared-wagon + * partner (same route and direction, customs, odd 20ft, not already paired). + */ + listConsolidationCandidates: async ( + id: string, + bookingId: string, + ): Promise => { + const response = await client.get( + C.CONSOLIDATION_CANDIDATES(id, bookingId), + ); + return (unwrap(response.data) ?? []) as ConsolidationCandidate[]; + }, + + /** + * Complete an odd-20ft booking together with the partner booking sharing its + * wagon. All-or-nothing on the server: either both bookings complete and are + * linked, or neither does. Each booking keeps its own price and its own + * invoice — only the wagon is shared. + */ + completeConsolidatedPair: async ( + id: string, + bookingId: string, + payload: CompleteConsolidatedPairPayload, + ): Promise<{ + booking: { id: string; reference: string }; + partner: { id: string; reference: string }; + warnings?: string[]; + }> => { + const response = await client.post( + C.BOOKINGS_COMPLETE_CONSOLIDATED(id, bookingId), + payload, + ); + return unwrap(response.data) as { + booking: { id: string; reference: string }; + partner: { id: string; reference: string }; + warnings?: string[]; + }; + }, + /** * Pre-create validation + authoritative price preview: the same * BookingPricingService pass that prices the booking on create (rail + diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 510139731..d3d6c0293 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -296,6 +296,12 @@ export interface BookingListRow { governmentInstitution?: string | null; consolidationPartnerId?: string | null; consolidationPartnerReference?: string | null; + /** + * The other half of a consolidated pair, folded into this row for display. + * Set client-side when both halves are present in the same page of results — + * the list shows one row per shared wagon, not one per booking. + */ + pairedWith?: BookingListRow | null; customsClearingEnabled?: boolean; /** * Derived booking kind for the list "Type" column. Mirrors the server's diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 001e55966..cfddea8b3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -163,11 +163,17 @@ export function Step8Review({ .join(", ") : ""; - // 20ft containers must pair up (two per wagon) — an odd total blocks submit. + // 20ft containers must pair up (two per wagon). Without customs an odd total + // still blocks submit — nothing downstream can place the unpaired container. + // With customs it is allowed: Global Logistics completes the booking and links + // it to another customer's odd booking so the two share the wagon, so an odd + // count here is only a notice. const { hasOddUnit: hasOdd20ft, ft20Wagons: twentyFtCount } = values.cargoType === "container" ? calcWagons(values.containers ?? []) : { hasOddUnit: false, ft20Wagons: 0 }; + const oddPairsViaCustoms = hasOdd20ft && Boolean(values.customsClearingEnabled); + const oddBlocksSubmit = hasOdd20ft && !oddPairsViaCustoms; const isGeneralContract = values.bookingType === "general_contract"; // Both one-time and general contracts take the bulk amount from the cargo step @@ -543,7 +549,7 @@ export function Step8Review({ - {hasOdd20ft ? ( + {oddBlocksSubmit ? (

@@ -558,6 +564,20 @@ export function Step8Review({

+ ) : oddPairsViaCustoms ? ( + + +

+ Odd number of 20ft containers ({twentyFtCount}) +

+

+ 20ft containers travel two per wagon, so one of yours will + share a wagon with another shipment. Global Logistics + arranges the pairing when completing your booking — you are + billed only for your own containers. +

+
+
) : noWagonForSelectedDay ? ( @@ -587,7 +607,7 @@ export function Step8Review({ leftSection={} onClick={onSubmit} loading={submitPending} - disabled={submitPending || hasOdd20ft || noWagonForSelectedDay} + disabled={submitPending || oddBlocksSubmit || noWagonForSelectedDay} > Submit diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index 9c6974641..69f15e756 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -106,14 +106,14 @@ export default function NewShipmentRequestPage() { contract.cargoScope?.[0]; const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM"; - // 20ft containers ride two per wagon, so an odd total can never be planned — - // and GL's create-booking form blocks it too, so an odd request would only - // dead-end there. Same even-number rule the booking forms apply. + // 20ft containers ride two per wagon. An odd total leaves one unpaired, which + // is allowed here: on a customs contract GL completes the booking and links it + // to another customer's odd booking so the two share the wagon. The request is + // therefore informational only, not a block. const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0; const hasOdd20ft = ft20Requested % 2 === 1; const handleSubmit = () => { - if (hasOdd20ft) return; const dto: Freight.CreateBookingRequestDto = { contractRouteId: route?.id, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, @@ -220,17 +220,17 @@ export default function NewShipmentRequestPage() { {hasOdd20ft ? ( } title={`Odd number of 20ft containers (${ft20Requested})`} > - 20ft containers travel two per wagon, so they must be requested - in even numbers. Please add one more 20ft container or remove - one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "} - instead of {ft20Requested}). + 20ft containers travel two per wagon, so one of yours will + share a wagon with another shipment. Global Logistics arranges + the pairing when completing your booking — you are billed only + for your own containers. ) : null} @@ -281,7 +281,6 @@ export default function NewShipmentRequestPage() { leftSection={} loading={submit.isPending} onClick={handleSubmit} - disabled={hasOdd20ft} > Submit shipment request From 6823a32fee315e6d0079cd45adc3a46267b4477a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 12:55:11 +0000 Subject: [PATCH 26/60] feat: yard scoping to position --- .../migrations/3560000000000-YardPositions.ts | 47 ++ .../3570000000000-YardViewAllPermission.ts | 54 ++ .../src/modules/audit/audit-endpoints.ts | 5 + .../controllers/yard-positions.controller.ts | 85 +++ .../rule-engine/dto/yard-positions.dto.ts | 30 ++ .../entities/yard-position.entity.ts | 28 + .../modules/rule-engine/rule-engine.module.ts | 12 + .../services/yard-positions.service.ts | 194 +++++++ .../services/yard-scope.service.spec.ts | 139 +++++ .../services/yard-scope.service.ts | 186 +++++++ .../warehouse-inventory.controller.ts | 14 +- .../warehouses/warehouse-inventory.service.ts | 46 +- .../src/seed/freight-permissions.registry.ts | 25 + .../ruleEngine/RuleEngineResourcePage.tsx | 31 ++ .../src/pages/ruleEngine/YardDesksModal.tsx | 134 +++++ .../src/services/yardPositions.service.ts | 64 +++ .../src/user-management/AppMenuTabs.tsx | 508 +++++++++--------- .../location-management/LocationForm.tsx | 365 +++++++++++++ .../location-management/LocationTypeForm.tsx | 173 ++++++ .../location-management/LocationTypesTab.tsx | 204 +++++++ .../location-management/LocationsTab.tsx | 233 ++++++++ .../dto/locations/location.type.ts | 54 ++ .../user-management/hooks/useLocationTypes.ts | 93 ++++ .../src/user-management/hooks/useLocations.ts | 95 ++++ .../pages/location-management/index.tsx | 35 ++ .../backoffice/src/user-management/route.tsx | 5 + .../services/api/locationService.ts | 55 ++ 27 files changed, 2657 insertions(+), 257 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts create mode 100644 apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/YardDesksModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/yardPositions.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationForm.tsx create mode 100644 apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationTypeForm.tsx create mode 100644 apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationTypesTab.tsx create mode 100644 apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationsTab.tsx create mode 100644 apps/edr-freight-web/backoffice/src/user-management/dto/locations/location.type.ts create mode 100644 apps/edr-freight-web/backoffice/src/user-management/hooks/useLocationTypes.ts create mode 100644 apps/edr-freight-web/backoffice/src/user-management/hooks/useLocations.ts create mode 100644 apps/edr-freight-web/backoffice/src/user-management/pages/location-management/index.tsx create mode 100644 apps/edr-freight-web/backoffice/src/user-management/services/api/locationService.ts diff --git a/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts new file mode 100644 index 000000000..102a5b85d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Which desks work at which yard — the input to yard access scoping. + * + * Many-to-many: a position (what the user-management tree calls a department) + * can cover several yards, and a yard is staffed by several positions. The + * scope resolver reads it to answer "which yards may this caller touch?". + * + * `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions + * live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package + * and shared with the passenger app: a hard FK would let freight block an IAM + * delete, and would have to be dropped the day IAM moves to its own database. + * Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a + * soft-deleted position silently drops out of scope rather than granting it. + * + * The unique index is PARTIAL — soft-deleted rows must not block re-adding the + * same pair later. + */ +export class YardPositions3560000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE, + position_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_yard_positions_pair + ON freight.yard_positions (yard_id, position_id) + WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_yard_positions_position + ON freight.yard_positions (position_id) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts new file mode 100644 index 000000000..0a53bd530 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access + * scoping. + * + * The permission catalog is otherwise written by `EdrOrgSeeder`, which skips + * itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments, + * so a key added to the registry never reaches `iam.permissions` and cannot be + * granted to anyone — the bypass would exist in code and be unusable in the + * database. A migration is the one path that runs everywhere. + * + * Idempotent on `key`, which is the identity every consumer resolves by (the + * registry's uuid is only used where a seed row needs one). Skips silently when + * the freight application row is absent, since there is nothing to attach to. + */ +export class YardViewAllPermission3570000000000 implements MigrationInterface { + private static readonly KEY = 'edr_freight_app:yards:view_all'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO iam.permissions (id, key, name, application_id) + SELECT gen_random_uuid(), + $1::varchar, + '{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb, + a.id + FROM iam.application a + WHERE a.key = 'edr_freight_app' + AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`, + [YardViewAllPermission3570000000000.KEY], + ); + } + + /** + * Removes only the permission row itself. Any grant of it goes first, or the + * delete trips the position/role permission foreign keys — and a half-removed + * permission is worse than one left in place. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM iam.position_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [YardViewAllPermission3570000000000.KEY], + ); + await queryRunner.query( + `DELETE FROM iam.role_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [YardViewAllPermission3570000000000.KEY], + ); + await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [ + YardViewAllPermission3570000000000.KEY, + ]); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index d46f63a7d..17b2d1580 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -633,6 +633,11 @@ export const AUDIT_ENDPOINTS: Readonly> = { "DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"], // Yard + // Yard Position (desk↔yard mapping — an input to yard access scoping, so + // every change to it is evidence of who widened or narrowed someone's reach) + "PUT /api/yard-positions/yard/:yardId": ["Replace a yard's whole position set", "PUT", "Yard Position"], + "PUT /api/yard-positions/position/:positionId": ["Replace a position's whole yard set", "PUT", "Yard Position"], + "POST /api/yards": ["Create a yard", "POST", "Yard"], "PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"], "DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"], diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts new file mode 100644 index 000000000..44ae27607 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '@edr/api-common'; +import { StaffReference } from '../../../common/booking-guards'; +import { RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards'; +import { + ListYardPositionsQueryDto, + SetPositionYardsDto, + SetYardPositionsDto, +} from '../dto/yard-positions.dto'; +import { YardPositionsService } from '../services/yard-positions.service'; +import { YardScopeService } from '../services/yard-scope.service'; + +/** + * Desk↔yard mapping — which positions ("departments" in the user-management + * tree) staff which yard. It is yard configuration, so it is gated by the same + * rule-engine yard keys as the rest of the yards screen. + * + * Writes REPLACE the whole set for the side being edited. The admin UI submits + * the full multi-select value; a caller sending a delta will drop everything it + * omits. Both write paths flush the scope resolver's cache so a mapping change + * takes effect on the next request instead of up to a minute later. + */ +@ApiTags('yard-positions') +@Controller('yard-positions') +@ApiBearerAuth() +export class YardPositionsController { + constructor( + private readonly service: YardPositionsService, + private readonly scope: YardScopeService, + ) {} + + @Get() + @RuleEngineView('yards') + @ApiOperation({ summary: 'List desk↔yard mappings, optionally by yard or position' }) + list(@Query() query: ListYardPositionsQueryDto) { + return this.service.list(query); + } + + @Get('positions') + @RuleEngineView('yards') + @ApiOperation({ summary: 'Positions selectable as yard desks' }) + listPositions() { + return this.service.listSelectablePositions(); + } + + @Get('my-yards') + // Any signed-in staff member, NOT gated on the yards keys: this returns the + // caller's own access and nothing else, and the frontend needs it to + // preselect yard filters. Gating it on `rule_engine:yards:view` 403'd every + // desk that does not administer yards — i.e. exactly the users it is for. + @StaffReference() + @ApiOperation({ + summary: "The caller's own yard scope (null yardIds = unrestricted)", + }) + async myYards(@CurrentUser() user: unknown) { + const yardIds = await this.scope.getScopedYardIds(user as never); + return { yardIds, unrestricted: yardIds === null, enforced: this.scope.enforced }; + } + + @Put('yard/:yardId') + @RuleEngineUpdate('yards') + @ApiOperation({ summary: "Replace a yard's whole position set" }) + async setPositionsForYard( + @Param('yardId', ParseUUIDPipe) yardId: string, + @Body() dto: SetYardPositionsDto, + ) { + const rows = await this.service.setPositionsForYard(yardId, dto.positionIds); + this.scope.invalidate(); + return rows; + } + + @Put('position/:positionId') + @RuleEngineUpdate('yards') + @ApiOperation({ summary: "Replace a position's whole yard set" }) + async setYardsForPosition( + @Param('positionId', ParseUUIDPipe) positionId: string, + @Body() dto: SetPositionYardsDto, + ) { + const rows = await this.service.setYardsForPosition(positionId, dto.yardIds); + this.scope.invalidate(); + return rows; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts new file mode 100644 index 000000000..b18648d78 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class ListYardPositionsQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + positionId?: string; +} + +/** Replaces the yard's whole position set — see the controller's PUT docs. */ +export class SetYardPositionsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @IsUUID('4', { each: true }) + positionIds!: string[]; +} + +/** Replaces the position's whole yard set. */ +export class SetPositionYardsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @IsUUID('4', { each: true }) + yardIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts new file mode 100644 index 000000000..de12b5674 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts @@ -0,0 +1,28 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * One desk staffed at one yard. + * + * The pairing that yard access scoping resolves against: a caller's active + * position decides which yards they may touch. Position rows live in `iam` + * (`iam.positions` — what the user-management tree labels "departments"), so + * `positionId` is an unconstrained uuid by design; see the migration for why. + */ +@Entity({ schema: 'freight', name: 'yard_positions' }) +@Index(['yardId']) +@Index(['positionId']) +export class YardPosition extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** `iam.positions.id`. No FK — IAM is package-owned and soft-deletes. */ + @Column({ name: 'position_id', type: 'uuid' }) + positionId!: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 549a35fa2..97e89cc3b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -13,6 +13,7 @@ import { ShippingLinesController } from './controllers/shipping-lines.controller import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; import { YardDistancesController } from './controllers/yard-distances.controller'; import { YardsController } from './controllers/yards.controller'; +import { YardPositionsController } from './controllers/yard-positions.controller'; import { ApprovalRule } from './entities/approval-rule.entity'; import { CargoType } from './entities/cargo-type.entity'; @@ -28,6 +29,7 @@ import { Yard } from './entities/yard.entity'; import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; import { YardLocation } from './entities/yard-location.entity'; +import { YardPosition } from './entities/yard-position.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; @@ -65,6 +67,8 @@ import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; import { YardDistancesService } from './services/yard-distances.service'; import { YardFacilitiesService } from './services/yard-facilities.service'; +import { YardPositionsService } from './services/yard-positions.service'; +import { YardScopeService } from './services/yard-scope.service'; import { RuleEngineService } from './rule-engine.service'; @@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardDistance, YardFacility, YardLocation, + YardPosition, ShippingLine, Rate, ApprovalRule, @@ -116,6 +121,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesController, WeightLimitRulesController, YardsController, + YardPositionsController, YardDistancesController, ShippingLinesController, RatesController, @@ -152,6 +158,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardsService, YardDistancesService, YardFacilitiesService, + YardPositionsService, + YardScopeService, ShippingLinesService, RatesService, ApprovalRulesService, @@ -168,6 +176,10 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardsService, YardDistancesService, YardFacilitiesService, + YardPositionsService, + // Exported so any module can narrow its yard queries through the one + // resolver — the module is @Global, so no import is needed to inject it. + YardScopeService, ShippingLinesService, RatesService, ApprovalRulesService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts new file mode 100644 index 000000000..93f9752f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts @@ -0,0 +1,194 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; + +import { YardPosition } from '../entities/yard-position.entity'; +import { Yard } from '../entities/yard.entity'; + +/** A mapped desk, joined to its IAM position for display. */ +export interface YardPositionRow { + id: string; + yardId: string; + yardCode: string; + yardLabel: string; + positionId: string; + /** Localised name from `iam.positions.name` — null if the position is gone. */ + positionName: { am?: string; en?: string } | null; + positionTypeKey: string | null; +} + +/** + * The desk↔yard mapping behind yard access scoping. + * + * Reads always join `iam.positions` and drop soft-deleted rows: the mapping has + * no FK to IAM (see the migration), so a position deleted in the admin UI leaves + * an orphan row here. Dropping it on read means the orphan can never widen + * someone's scope — it just disappears. + */ +@Injectable() +export class YardPositionsService { + constructor(private readonly dataSource: DataSource) {} + + /** Mapping rows, optionally narrowed to one yard or one position. */ + async list(filter: { + yardId?: string; + positionId?: string; + }): Promise { + const params: unknown[] = []; + const where: string[] = ['yp.deleted_at IS NULL', 'y.deleted_at IS NULL']; + + if (filter.yardId) { + params.push(filter.yardId); + where.push(`yp.yard_id = $${params.length}`); + } + if (filter.positionId) { + params.push(filter.positionId); + where.push(`yp.position_id = $${params.length}`); + } + + return this.dataSource.query( + `SELECT yp.id AS "id", + yp.yard_id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + yp.position_id AS "positionId", + p.name AS "positionName", + pt.key AS "positionTypeKey" + FROM freight.yard_positions yp + JOIN freight.yards y ON y.id = yp.yard_id + -- INNER join: a mapping whose position was deleted grants nothing and + -- is not shown. The row stays for audit until someone re-saves the set. + JOIN iam.positions p ON p.id = yp.position_id AND p.deleted_at IS NULL + LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id + WHERE ${where.join(' AND ')} + ORDER BY y.display_order ASC, y.label ASC, p.name->>'en' ASC`, + params, + ); + } + + /** + * Replace the yard's entire position set. + * + * Replace, not append — the admin UI submits the full multi-select value, so a + * partial payload would silently keep desks the user just unticked. Callers + * sending a delta will remove everything they omit. + */ + async setPositionsForYard( + yardId: string, + positionIds: string[], + ): Promise { + await this.assertYardExists(yardId); + await this.assertPositionsExist(positionIds); + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(YardPosition); + await repo.delete({ yardId }); + if (positionIds.length) { + await repo.insert( + [...new Set(positionIds)].map((positionId) => ({ yardId, positionId })), + ); + } + }); + + return this.list({ yardId }); + } + + /** Replace the position's entire yard set. Same replace semantics. */ + async setYardsForPosition( + positionId: string, + yardIds: string[], + ): Promise { + await this.assertPositionsExist([positionId]); + await this.assertYardsExist(yardIds); + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(YardPosition); + await repo.delete({ positionId }); + if (yardIds.length) { + await repo.insert( + [...new Set(yardIds)].map((yardId) => ({ yardId, positionId })), + ); + } + }); + + return this.list({ positionId }); + } + + /** + * Positions offered by the mapping picker. + * + * Reads `iam.positions` directly rather than going through IAM's + * `/positions/list/{unitId}`: that endpoint needs the caller to resolve a unit + * first, and the picker wants every desk that could staff a yard regardless of + * which unit it hangs under. + */ + async listSelectablePositions(): Promise< + Array<{ + id: string; + name: { am?: string; en?: string } | null; + positionTypeKey: string | null; + unitKey: string | null; + }> + > { + return this.dataSource.query( + `SELECT p.id AS "id", + p.name AS "name", + pt.key AS "positionTypeKey", + u.key AS "unitKey" + FROM iam.positions p + LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id + LEFT JOIN iam.units u ON u.id = p.unit_id + WHERE p.deleted_at IS NULL + ORDER BY p.name->>'en' ASC`, + ); + } + + /** Yard ids mapped to any of these positions — the scope resolver's read. */ + async yardIdsForPositions(positionIds: string[]): Promise { + if (!positionIds.length) return []; + const rows: { yardId: string }[] = await this.dataSource.query( + `SELECT DISTINCT yp.yard_id AS "yardId" + FROM freight.yard_positions yp + JOIN freight.yards y ON y.id = yp.yard_id AND y.deleted_at IS NULL + WHERE yp.deleted_at IS NULL + AND yp.position_id = ANY($1)`, + [positionIds], + ); + return rows.map((r) => r.yardId); + } + + private async assertYardExists(yardId: string): Promise { + const yard = await this.dataSource + .getRepository(Yard) + .findOne({ where: { id: yardId, deletedAt: IsNull() } }); + if (!yard) throw new NotFoundException(`Yard ${yardId} not found`); + } + + private async assertYardsExist(yardIds: string[]): Promise { + if (!yardIds.length) return; + const found = await this.dataSource + .getRepository(Yard) + .count({ where: { id: In(yardIds), deletedAt: IsNull() } }); + if (found !== new Set(yardIds).size) { + throw new BadRequestException('One or more yards do not exist'); + } + } + + /** + * Validated in the service because the database cannot: there is no FK to + * `iam.positions`, so an unchecked payload would happily store a typo'd uuid + * that silently grants nothing and reads as a configuration bug later. + */ + private async assertPositionsExist(positionIds: string[]): Promise { + if (!positionIds.length) return; + const unique = [...new Set(positionIds)]; + const rows: { count: string }[] = await this.dataSource.query( + `SELECT COUNT(*)::text AS count + FROM iam.positions + WHERE id = ANY($1) AND deleted_at IS NULL`, + [unique], + ); + if (Number(rows[0]?.count ?? 0) !== unique.length) { + throw new BadRequestException('One or more positions do not exist'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts new file mode 100644 index 000000000..c9af80ad3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts @@ -0,0 +1,139 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { YardScopeService } from './yard-scope.service'; + +/** + * The resolver answers "which yards", never "may they act at all" — that stays + * with the permission guard. So a mapped desk is narrowed to its yards, and an + * unmapped one keeps the reach its permissions already gave it. + */ +describe('YardScopeService', () => { + const yardIdsForPositions = jest.fn(); + const service = () => + new YardScopeService({ yardIdsForPositions } as never); + + const staff = (positionId: string, permissions: string[] = []) => ({ + roles: [{ key: 'staff' }], + permissions: permissions.map((key) => ({ key })), + employee: { position: { id: positionId, permissions: [] } }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.YARD_SCOPE_ENFORCE; + }); + + it('resolves a mapped position to its yards', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + const scope = await service().getScopedYardIds(staff('pos-officer')); + + expect(scope).toEqual(['yard-kality', 'yard-mojo']); + expect(yardIdsForPositions).toHaveBeenCalledWith(['pos-officer']); + }); + + it('leaves an unmapped position unrestricted — permissions still gate the action', async () => { + yardIdsForPositions.mockResolvedValue([]); + + expect(await service().getScopedYardIds(staff('pos-unmapped'))).toBeNull(); + }); + + it('leaves a caller with no resolvable position unrestricted', async () => { + const noPosition = { roles: [{ key: 'staff' }], employee: { position: {} } }; + + expect(await service().getScopedYardIds(noPosition)).toBeNull(); + expect(yardIdsForPositions).not.toHaveBeenCalled(); + }); + + it('narrows nothing for an anonymous caller but grants nothing either', async () => { + expect(await service().getScopedYardIds(null)).toEqual([]); + }); + + it('returns unrestricted only for super admins and view_all holders', async () => { + const superAdmin = { roles: [{ key: 'super_admin' }] }; + const hqDesk = staff('pos-occ', ['edr_freight_app:yards:view_all']); + + expect(await service().getScopedYardIds(superAdmin)).toBeNull(); + expect(await service().getScopedYardIds(hqDesk)).toBeNull(); + expect(yardIdsForPositions).not.toHaveBeenCalled(); + }); + + it('includes delegated positions — standing in must not lose the yard', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + await service().getScopedYardIds({ + roles: [{ key: 'staff' }], + employee: { + position: { id: 'pos-own' }, + delegatedPositions: [{ id: 'pos-gelan-director' }], + }, + }); + + expect(yardIdsForPositions).toHaveBeenCalledWith([ + 'pos-own', + 'pos-gelan-director', + ]); + }); + + describe('listFilterYardIds', () => { + it('narrows nothing while shadow-logging', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'), + ).toBeNull(); + }); + + it('narrows to the mapped yards once enforcing', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'), + ).toEqual(['yard-kality', 'yard-mojo']); + }); + + it('keeps an in-scope yard filter as the caller asked', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), 'yard-mojo', 'list'), + ).toEqual(['yard-mojo']); + }); + + it('returns an empty set — not everything — for an out-of-scope yard filter', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), 'yard-djibouti', 'list'), + ).toEqual([]); + }); + + it('never narrows an unmapped desk', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue([]); + + expect( + await service().listFilterYardIds(staff('pos-unmapped'), undefined, 'list'), + ).toBeNull(); + }); + }); + + it('only logs an out-of-scope yard until YARD_SCOPE_ENFORCE is set', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + const shadow = service(); + + await expect( + shadow.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'), + ).resolves.toBeUndefined(); + + process.env.YARD_SCOPE_ENFORCE = 'true'; + const enforcing = service(); + + await expect( + enforcing.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts new file mode 100644 index 000000000..0a7fbe2e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts @@ -0,0 +1,186 @@ +import { ForbiddenException, Injectable, Logger } from "@nestjs/common"; + +import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; +import { YardPositionsService } from "./yard-positions.service"; + +/** + * Caller shape the resolver reads — the `/auth/me` user in either of its two + * shapes. Structurally compatible with what `freight-permission.util` accepts, + * so the same object serves both the permission checks and the position walk. + */ +type PositionLike = { + id?: string; + permissions?: { key?: string }[]; + positionType?: { key?: string } | null; +}; + +type ScopeUser = { + roles?: { key?: string }[]; + permissions?: { key?: string }[]; + employee?: + | { + position?: PositionLike; + delegatedPositions?: PositionLike[]; + } + | { positions?: PositionLike[] }[] + | null; +}; + +/** + * Which yards a caller may touch. + * + * Scope follows the caller's ACTIVE position, not a union of every position they + * have ever held: the frontends already send `x-current-position-id` and the + * token snapshots that one position, so switching desks switches yards — which + * is what staff covering two yards actually do. Delegated positions are added on + * top, otherwise standing in for the Gelan director silently loses Gelan. + * + * `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a + * desk that has been given yards; it does not hand out access. Whether the + * caller may perform the action at all is the permission guard's job — this + * resolver only answers "which yards", so a desk with the permission and no + * mapping keeps the reach it had before the mapping existed. + * + * The trade-off is deliberate and worth knowing: an accidentally-cleared + * mapping widens access rather than blocking work, so the mapping is not a + * containment barrier on its own — the permission keys still are. Super admins + * and holders of `yards:view_all` are unrestricted regardless of mapping. + * + * ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then + * {@link assertYardInScope} logs what it would have blocked and returns. Flip it + * only once the mapping table is populated and the log is quiet — on an empty + * table, enforcing locks out every staff member at once. + */ +@Injectable() +export class YardScopeService { + private readonly logger = new Logger(YardScopeService.name); + + // ponytail: 60s cache keyed by the position-id set, no invalidation hook. A + // mapping change takes up to a minute to reach the resolver. Call + // `invalidate()` from the mutation if that lag ever matters. + private static readonly CACHE_TTL_MS = 60_000; + private readonly cache = new Map(); + + constructor(private readonly yardPositions: YardPositionsService) {} + + /** True when the deny path is live; false while shadow-logging. */ + get enforced(): boolean { + return process.env.YARD_SCOPE_ENFORCE === "false"; + } + + /** Yard ids the caller is scoped to, or `null` for unrestricted. */ + async getScopedYardIds(user: ScopeUser | null | undefined): Promise { + // No user at all is an unauthenticated call the guards should already have + // rejected — narrow to nothing rather than trusting it. + if (!user) return []; + if (isSuperAdmin(user)) return null; + if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null; + + const positionIds = this.effectivePositionIds(user); + // No resolvable position — nothing to narrow by, so nothing is narrowed. + if (!positionIds.length) return null; + + const key = positionIds.join(","); + const hit = this.cache.get(key); + if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) { + return hit.yardIds.length ? hit.yardIds : null; + } + + const yardIds = await this.yardPositions.yardIdsForPositions(positionIds); + this.cache.set(key, { yardIds, at: Date.now() }); + // Unmapped desk → unrestricted. Mapping narrows; absence of one does not. + return yardIds.length ? yardIds : null; + } + + async isYardInScope( + user: ScopeUser | null | undefined, + yardId: string | null | undefined, + ): Promise { + if (!yardId) return true; + const scope = await this.getScopedYardIds(user); + return scope === null || scope.includes(yardId); + } + + /** + * Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only + * logs — wire it into write paths first and read filters second, so the + * shadow log shows what enforcement would break before it breaks it. + */ + async assertYardInScope( + user: ScopeUser | null | undefined, + yardId: string | null | undefined, + context: string, + ): Promise { + if (await this.isYardInScope(user, yardId)) return; + + const positions = this.effectivePositionIds(user).join(",") || "none"; + if (!this.enforced) { + this.logger.warn( + `[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`, + ); + return; + } + throw new ForbiddenException("This yard is outside your assigned yards"); + } + + /** + * Yard ids a list query should be narrowed to, or `null` for no narrowing. + * + * Returns an EMPTY array only when the caller explicitly asked for a yard + * outside their scope and enforcement is on — the caller should answer with an + * empty result rather than silently widening back to everything. + * + * While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what + * it would have narrowed, so the mapping can be populated against real traffic + * before it starts hiding rows. + */ + async listFilterYardIds( + user: ScopeUser | null | undefined, + requestedYardId: string | null | undefined, + context: string, + ): Promise { + const scope = await this.getScopedYardIds(user); + if (scope === null) return null; + + const outOfScope = !!requestedYardId && !scope.includes(requestedYardId); + + if (!this.enforced) { + this.logger.warn( + `[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` + + (outOfScope ? ` and reject yard=${requestedYardId}` : ""), + ); + return null; + } + + if (outOfScope) return []; + return requestedYardId ? [requestedYardId] : scope; + } + + /** Drops the memoised scopes — call after editing the mapping. */ + invalidate(): void { + this.cache.clear(); + } + + /** Active position plus any delegated ones, across both `employee` shapes. */ + private effectivePositionIds(user: ScopeUser | null | undefined): string[] { + const ids = new Set(); + const employee = user?.employee; + if (!employee) return []; + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const position of emp.positions ?? []) { + if (position?.id) ids.add(position.id); + } + } + return [...ids]; + } + + if (employee.position?.id) ids.add(employee.position.id); + for (const delegated of employee.delegatedPositions ?? []) { + if (delegated?.id) ids.add(delegated.id); + } + return [...ids]; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 459b5009e..19d3b4b7a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -38,15 +38,21 @@ export class WarehouseInventoryController { @Get() @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List warehouse inventory' }) - findAll(@Query() filter: FilterWarehouseInventoryDto) { - return this.inventoryService.findAll(filter); + findAll( + @Query() filter: FilterWarehouseInventoryDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.findAll(filter, user); } @Get('ready-for-loading') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List inventory ready for loading' }) - findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { - return this.inventoryService.findReadyForLoading(filter); + findReadyForLoading( + @Query() filter: FilterWarehouseInventoryDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.findReadyForLoading(filter, user); } @Get('inquiry') 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 7412f0269..126998bae 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 @@ -39,6 +39,7 @@ import { import { SignaturesService } from '../signatures/signatures.service'; import { StampSettingsService } from '../stamp-settings/stamp-settings.service'; import { LogoSettingsService } from '../logo-settings/logo-settings.service'; +import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'; import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; @@ -423,6 +424,7 @@ export class WarehouseInventoryService { private readonly events: EventEmitter2, private readonly stampSettings: StampSettingsService, private readonly logoSettings: LogoSettingsService, + private readonly yardScope: YardScopeService, ) {} /** @@ -978,7 +980,16 @@ export class WarehouseInventoryService { // ── Listing ──────────────────────────────────────────────────────────── - async findAll(filter: FilterWarehouseInventoryDto): Promise { + /** + * `user` drives yard access scoping: a desk mapped to yards sees only those + * yards' inventory. Optional so internal callers that are not serving a + * request (schedulers, other services) are unaffected — they pass nothing and + * get the unscoped list, which is what they had before. + */ + async findAll( + filter: FilterWarehouseInventoryDto, + user?: unknown, + ): Promise { const createdAt = filter.dateFrom && filter.dateTo ? Between(new Date(filter.dateFrom), new Date(filter.dateTo)) @@ -1020,6 +1031,32 @@ export class WarehouseInventoryService { }); } + // Yard scoping — applied to `base` before the search branch splits it, so + // both OR arms carry the constraint. A null result means "do not narrow". + // + // Scoped on `warehouse.stationId`, NOT on `inventory.yardId`: those are two + // different id spaces that share a name. `warehouse_inventory.yard_id` is a + // FK to `warehouse_yards` — a yard INSIDE a warehouse — while the desk↔yard + // mapping is against `freight.yards`, the network yard, which inventory + // reaches through `warehouses.station_id`. Filtering `yardId` against + // mapped network yards matches nothing and hides every row (observed: all + // 34 rows disappeared before this was corrected). + // + // `filter.yardId` is likewise a warehouse-yard id, so it is NOT passed as + // the requested yard here; `filter.facilityId` is the station-yard filter. + const scopedYardIds = await this.yardScope.listFilterYardIds( + user as never, + filter.facilityId, + 'warehouse-inventory list', + ); + if (scopedYardIds) { + if (!scopedYardIds.length) return []; + base.warehouse = { + ...((base.warehouse as FindOptionsWhere) ?? {}), + stationId: scopedYardIds.length === 1 ? scopedYardIds[0] : In(scopedYardIds), + }; + } + const search = filter.search?.trim(); const where: FindManyOptions['where'] = search ? [ @@ -1037,8 +1074,11 @@ export class WarehouseInventoryService { return items; } - findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { - return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); + findReadyForLoading( + filter: FilterWarehouseInventoryDto, + user?: unknown, + ): Promise { + return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }, user); } /** diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 01c1d67d0..e39b08b59 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -459,6 +459,26 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +/** + * Yard access scoping. `yard_positions` maps desks to yards and the resolver + * (`YardScopeService`) narrows a caller to the yards their active position is + * mapped to. This key is the deliberate way out of that narrowing, for the HQ + * desks that are cross-yard by nature (OCC, CEO, rolling stock). Without it, + * "unmapped" would have to mean "sees everything", which is a bypass by + * accident rather than by grant. + * + * Editing the mapping itself needs no key of its own: it is yard configuration, + * so it rides on `rule_engine:yards:view` / `:update` like every other field on + * a yard. + */ +export const YARD_SCOPE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f4a00001-0001-4000-8000-000000000001", + "edr_freight_app:yards:view_all", + "Access every yard (bypass yard scoping)", + ), +]; + /** * Advanced backoffice resources — full CRUD + workflow-action keys. * See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing @@ -1667,6 +1687,7 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, ...GAP_CONTROLLER_PERMISSIONS, + ...YARD_SCOPE_PERMISSIONS, ...ADVANCED_BACKOFFICE_PERMISSIONS, ]; @@ -1844,6 +1865,10 @@ export const FREIGHT_PERMS = { allocation: { manage: "edr_freight_app:allocation:manage", }, + yards: { + /** Bypasses yard scoping entirely — see YARD_SCOPE_PERMISSIONS. */ + viewAll: "edr_freight_app:yards:view_all", + }, customers: { view: "edr_freight_app:customers:view", create: "edr_freight_app:customers:create", diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 688ed3ce2..a83a90fb4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -26,6 +26,7 @@ import { PageContainer, PageHeader } from "@/components/page"; import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog"; import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection"; import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection"; +import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal"; import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; @@ -167,6 +168,10 @@ const RuleEngineResourcePage = () => { null, ); const [chainOpen, setChainOpen] = useState(false); + // Yards only: which desks work at this yard (input to yard access scoping). + const [desksYard, setDesksYard] = useState | null>( + null, + ); const [orderDialogOpen, setOrderDialogOpen] = useState(false); const { viewMode, setViewMode } = useRuleEngineViewMode( config?.slug ?? DEFAULT_CONFIGURATION_SLUG, @@ -566,6 +571,17 @@ const RuleEngineResourcePage = () => { cell: ({ row }) => (
e.stopPropagation()} data-stop-row-click> + {config.slug === "yards" ? ( + + + + ) : null} {config.orderConfig && canUpdateControls ? ( { + setDesksYard(null)} + readOnly={!canUpdateControls} + yard={ + desksYard + ? { + id: String(desksYard.id), + code: String(desksYard.code ?? ""), + label: String(desksYard.label ?? ""), + } + : null + } + /> + void; + yard: { id: string; code: string; label: string } | null; + /** Read-only when the caller lacks the yards update permission. */ + readOnly?: boolean; +} + +const positionLabel = ( + name: { am?: string; en?: string } | null, + fallback: string, +) => name?.en?.trim() || name?.am?.trim() || fallback; + +/** + * Which desks staff a yard — the input to yard access scoping. + * + * Saving REPLACES the yard's whole set (the API's PUT is a replace), which is + * why the control is a multi-select holding the complete list rather than + * add/remove buttons. + */ +export function YardDesksModal({ + opened, + onClose, + yard, + readOnly = false, +}: YardDesksModalProps) { + const queryClient = useQueryClient(); + const [selected, setSelected] = useState([]); + + const positions = useQuery({ + queryKey: ["yard-positions", "positions"], + queryFn: yardPositionsService.listPositions, + enabled: opened, + staleTime: 5 * 60 * 1000, + }); + + const mapping = useQuery({ + queryKey: ["yard-positions", "yard", yard?.id], + queryFn: () => yardPositionsService.listByYard(yard!.id), + enabled: opened && !!yard?.id, + }); + + // Reset to what the server holds whenever the modal opens on a new yard, so a + // cancelled edit never leaks into the next one. + useEffect(() => { + if (mapping.data) setSelected(mapping.data.map((row) => row.positionId)); + }, [mapping.data]); + + const save = useMutation({ + mutationFn: () => yardPositionsService.setForYard(yard!.id, selected), + onSuccess: () => { + toast.success("Yard desks updated"); + queryClient.invalidateQueries({ queryKey: ["yard-positions"] }); + onClose(); + }, + onError: (error) => + toast.error(extractErrorMessage(error, "Failed to update yard desks")), + }); + + const options = (positions.data ?? []).map((position) => ({ + value: position.id, + label: positionLabel(position.name, position.id.slice(0, 8)), + })); + + return ( + + + + + Positions mapped here are the desks that work at this yard. Yard + access scoping reads this mapping — a staff member acting on this + desk is scoped to this yard. + + + + {positions.isLoading || mapping.isLoading ? ( + + + + ) : ( + + )} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/yardPositions.service.ts b/apps/edr-freight-web/backoffice/src/services/yardPositions.service.ts new file mode 100644 index 000000000..db8984827 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/yardPositions.service.ts @@ -0,0 +1,64 @@ +import { api as apiClient } from "../auth/http"; + +// NOTE: `auth/http`'s response interceptor already unwraps the API's +// `{ success, data }` envelope, so `response.data` IS the payload here — a +// second `.data` hop reads undefined and silently yields an empty list. + +/** A desk mapped to a yard, joined to its IAM position for display. */ +export interface YardPositionRow { + id: string; + yardId: string; + yardCode: string; + yardLabel: string; + positionId: string; + positionName: { am?: string; en?: string } | null; + positionTypeKey: string | null; +} + +export interface SelectablePosition { + id: string; + name: { am?: string; en?: string } | null; + positionTypeKey: string | null; + unitKey: string | null; +} + +export interface MyYardScope { + /** null = unrestricted (super admin or `yards:view_all`). */ + yardIds: string[] | null; + unrestricted: boolean; + /** False while the backend is still shadow-logging instead of denying. */ + enforced: boolean; +} + +export const yardPositionsService = { + listByYard: async (yardId: string): Promise => { + const { data } = await apiClient.get(`/yard-positions`, { + params: { yardId }, + }); + return data ?? []; + }, + + listPositions: async (): Promise => { + const { data } = await apiClient.get(`/yard-positions/positions`); + return data ?? []; + }, + + myScope: async (): Promise => { + const { data } = await apiClient.get(`/yard-positions/my-yards`); + return data; + }, + + /** + * Replaces the yard's whole desk set — send every position that should remain + * mapped, not just the additions. + */ + setForYard: async ( + yardId: string, + positionIds: string[], + ): Promise => { + const { data } = await apiClient.put(`/yard-positions/yard/${yardId}`, { + positionIds, + }); + return data ?? []; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx index 127806334..4e8f30561 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx @@ -1,250 +1,258 @@ -import { Link, useLocation } from "react-router-dom"; -import { - Archive, - BarChart, - Building2, - ChartAreaIcon, - ClipboardList, - FileText, - Globe, - Settings, - Users2, - UsersRound, -} from "lucide-react"; -import { useTranslation } from "react-i18next"; - -import { useAuth } from "@/shared/context/AuthContext"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/shared/common/ui/sidebar"; - -export interface MenuItem { - label: string; - href: string; - icon: React.ReactNode; - roles?: string[]; - /** Sidebar section this item is bucketed under. */ - group: string; -} - -// Section render order; groups with no role-visible items are skipped. -const GROUP_ORDER = [ - "Overview", - "Organizations", - "Content", - "Records", - "Configuration", - "Archive", - "System", -]; - -export const AppMenuTabs = () => { - const { user } = useAuth(); - const { pathname } = useLocation(); - const { setOpenMobile } = useSidebar(); - const { t } = useTranslation(); - - const userRoles = user?.roles.map((role) => role.key) || []; - - const menuItems: MenuItem[] = [ - { - label: "dashboard", - href: "/user-management/dashboard", - icon: , - roles: ["super_admin"], - group: "Overview", - }, - { - label: "organizations", - href: "/user-management/organizations", - icon: , - roles: ["super_admin"], - group: "Organizations", - }, - { - label: "organizationAdmins", - href: "/user-management/organization_admins", - icon: , - roles: ["super_admin"], - group: "Organizations", - }, - { - label: "externalUsers", - href: "/user-management/external_users", - icon: , - roles: ["super_admin"], - group: "Organizations", - }, - { - label: "dashboard", - href: "/user-management/user_management-dashboard", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Overview", - }, - { - label: "userManagement", - href: "/user-management/user_management", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Overview", - }, - { - label: "contentManagement", - href: "/user-management/content-management", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Content", - }, - { - label: "webManagement", - href: "/user-management/web-management", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Content", - }, - { - label: "Bulk", - href: "/user-management/bulk-upload", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Content", - }, - { - label: "Position", - href: "/user-management/position-management", - icon: , - roles: ["admin", "organization_admin", "unit_admin", "super_admin"], - group: "Configuration", - }, - { - label: "settings", - href: "/user-management/organization-settings", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Configuration", - }, - { - label: "Add Site", - href: "/user-management/add-site", - icon: , - roles: ["super_admin"], - group: "Configuration", - }, - { - label: "migratedRecords", - href: "/user-management/migrated-records-management", - icon: , - roles: ["super_admin"], - group: "Records", - }, - { - label: "Sector Reports", - href: "/user-management/sector-reports", - icon: , - roles: ["unit_admin", "admin", "organization_admin"], - group: "Records", - }, - { - label: "Archive Users", - href: "/user-management/archive-users", - icon: , - roles: ["super_admin"], - group: "Archive", - }, - { - label: "Archived Organizations", - href: "/user-management/archived-organizations", - icon: , - roles: ["super_admin"], - group: "Archive", - }, - { - label: "Archive Users", - href: "/user-management/archives", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Archive", - }, - { - label: "Archived Units & Positions", - href: "/user-management/archived", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Archive", - }, - { - label: "activityLog", - href: "/user-management/activity_log", - icon: , - roles: ["super_admin"], - group: "System", - }, - { - label: "setting", - href: "/user-management/settings", - icon: , - roles: ["super_admin"], - group: "System", - }, - { - label: "Letter Template", - href: "/user-management/templates", - icon: , - roles: ["super_admin"], - group: "System", - }, - ]; - - const filteredMenu = menuItems.filter((item) => - item.roles?.some((r) => userRoles.includes(r)), - ); - - const isActive = (href: string) => - pathname === href || pathname.startsWith(`${href}/`); - - return ( - <> - {GROUP_ORDER.map((group) => { - const items = filteredMenu.filter((item) => item.group === group); - if (items.length === 0) return null; - - return ( - - {group} - - - {items.map((item) => { - const label = t(`organization.${item.label}`, item.label); - return ( - - - setOpenMobile(false)} - > - {item.icon} - {label} - - - - ); - })} - - - - ); - })} - - ); -}; +import { Link, useLocation } from "react-router-dom"; +import { + Archive, + BarChart, + Building2, + ChartAreaIcon, + ClipboardList, + FileText, + Globe, + MapPin, + Settings, + Users2, + UsersRound, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { useAuth } from "@/shared/context/AuthContext"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/shared/common/ui/sidebar"; + +export interface MenuItem { + label: string; + href: string; + icon: React.ReactNode; + roles?: string[]; + /** Sidebar section this item is bucketed under. */ + group: string; +} + +// Section render order; groups with no role-visible items are skipped. +const GROUP_ORDER = [ + "Overview", + "Organizations", + "Content", + "Records", + "Configuration", + "Archive", + "System", +]; + +export const AppMenuTabs = () => { + const { user } = useAuth(); + const { pathname } = useLocation(); + const { setOpenMobile } = useSidebar(); + const { t } = useTranslation(); + + const userRoles = user?.roles.map((role) => role.key) || []; + + const menuItems: MenuItem[] = [ + { + label: "dashboard", + href: "/user-management/dashboard", + icon: , + roles: ["super_admin"], + group: "Overview", + }, + { + label: "organizations", + href: "/user-management/organizations", + icon: , + roles: ["super_admin"], + group: "Organizations", + }, + { + label: "organizationAdmins", + href: "/user-management/organization_admins", + icon: , + roles: ["super_admin"], + group: "Organizations", + }, + { + label: "externalUsers", + href: "/user-management/external_users", + icon: , + roles: ["super_admin"], + group: "Organizations", + }, + { + label: "dashboard", + href: "/user-management/user_management-dashboard", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Overview", + }, + { + label: "userManagement", + href: "/user-management/user_management", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Overview", + }, + { + label: "contentManagement", + href: "/user-management/content-management", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Content", + }, + { + label: "webManagement", + href: "/user-management/web-management", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Content", + }, + { + label: "Bulk", + href: "/user-management/bulk-upload", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Content", + }, + { + label: "Position", + href: "/user-management/position-management", + icon: , + roles: ["admin", "organization_admin", "unit_admin", "super_admin"], + group: "Configuration", + }, + { + label: "Locations", + href: "/user-management/locations", + icon: , + roles: ["admin", "organization_admin", "unit_admin", "super_admin"], + group: "Configuration", + }, + { + label: "settings", + href: "/user-management/organization-settings", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Configuration", + }, + { + label: "Add Site", + href: "/user-management/add-site", + icon: , + roles: ["super_admin"], + group: "Configuration", + }, + { + label: "migratedRecords", + href: "/user-management/migrated-records-management", + icon: , + roles: ["super_admin"], + group: "Records", + }, + { + label: "Sector Reports", + href: "/user-management/sector-reports", + icon: , + roles: ["unit_admin", "admin", "organization_admin"], + group: "Records", + }, + { + label: "Archive Users", + href: "/user-management/archive-users", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archived Organizations", + href: "/user-management/archived-organizations", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archive Users", + href: "/user-management/archives", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "Archived Units & Positions", + href: "/user-management/archived", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "activityLog", + href: "/user-management/activity_log", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "setting", + href: "/user-management/settings", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "Letter Template", + href: "/user-management/templates", + icon: , + roles: ["super_admin"], + group: "System", + }, + ]; + + const filteredMenu = menuItems.filter((item) => + item.roles?.some((r) => userRoles.includes(r)), + ); + + const isActive = (href: string) => + pathname === href || pathname.startsWith(`${href}/`); + + return ( + <> + {GROUP_ORDER.map((group) => { + const items = filteredMenu.filter((item) => item.group === group); + if (items.length === 0) return null; + + return ( + + {group} + + + {items.map((item) => { + const label = t(`organization.${item.label}`, item.label); + return ( + + + setOpenMobile(false)} + > + {item.icon} + {label} + + + + ); + })} + + + + ); + })} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationForm.tsx new file mode 100644 index 000000000..aeb03cd71 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationForm.tsx @@ -0,0 +1,365 @@ +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + APIProvider, + Map as GoogleMap, + Marker, + type MapMouseEvent, +} from "@vis.gl/react-google-maps"; + +import { Button } from "@/shared/common/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/common/ui/form"; +import { Input } from "@/shared/common/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { Textarea } from "@/shared/common/ui/textarea"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import type { + Location, + LocationPayload, + LocationType, +} from "@/user-management/dto/locations/location.type"; +import { useLocations } from "@/user-management/hooks/useLocations"; + +const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim(); +/** Addis Ababa — where every EDR location is within a map pan. */ +const DEFAULT_CENTER = { lat: 9.032, lng: 38.7469 }; + +const NO_PARENT = "__none__"; + +const numeric = (label: string) => + z + .string() + .trim() + .optional() + .refine((v) => !v || !Number.isNaN(Number(v)), `${label} must be a number`); + +const locationSchema = z.object({ + nameAm: z.string().trim().min(1, "Amharic name is required"), + nameEn: z.string().trim().optional(), + code: z.string().trim().min(1, "Code is required"), + locationTypeId: z.string().uuid("Location type is required"), + parentId: z.string().optional(), + latitude: numeric("Latitude"), + longitude: numeric("Longitude"), + area: numeric("Area"), + boundaryJson: z + .string() + .trim() + .optional() + .refine((v) => { + if (!v) return true; + try { + const parsed = JSON.parse(v); + return typeof parsed === "object" && parsed !== null; + } catch { + return false; + } + }, "Boundary must be a JSON object"), +}); + +export type LocationFormValues = z.infer; + +interface LocationFormProps { + mode: "create" | "edit"; + location?: Location; + locationTypes: LocationType[]; + /** Every location, for the parent picker — the API has no filter endpoint. */ + allLocations: Location[]; + onSuccess?: () => void; +} + +export function LocationForm({ + mode, + location, + locationTypes, + allLocations, + onSuccess, +}: LocationFormProps) { + const localizedName = useLocalizedName(); + const { createLocation, updateLocation, isCreatingLocation, isUpdatingLocation } = + useLocations(); + + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: { + nameAm: location?.names?.am ?? "", + nameEn: location?.names?.en ?? "", + code: location?.code ?? "", + locationTypeId: location?.locationTypeId ?? "", + parentId: location?.parentId ?? NO_PARENT, + latitude: location?.latitude ?? "", + longitude: location?.longitude ?? "", + area: location?.area ?? "", + boundaryJson: location?.boundaryJson + ? JSON.stringify(location.boundaryJson, null, 2) + : "", + }, + }); + + const [lat, lng] = [form.watch("latitude"), form.watch("longitude")]; + const pin = + lat && lng && !Number.isNaN(Number(lat)) && !Number.isNaN(Number(lng)) + ? { lat: Number(lat), lng: Number(lng) } + : null; + + const dropPin = (event: MapMouseEvent) => { + const point = event.detail.latLng; + if (!point) return; + form.setValue("latitude", point.lat.toFixed(6), { shouldDirty: true }); + form.setValue("longitude", point.lng.toFixed(6), { shouldDirty: true }); + }; + + // ponytail: self only, not descendants — the API accepts any parentId, so a + // deep cycle (A → B → A) is still possible. Walk the chain here if it bites. + const parentOptions = allLocations.filter((item) => item.id !== location?.id); + + const submit = (values: LocationFormValues) => { + const payload: LocationPayload = { + names: { + am: values.nameAm, + ...(values.nameEn ? { en: values.nameEn } : {}), + }, + code: values.code, + locationTypeId: values.locationTypeId, + parentId: + values.parentId && values.parentId !== NO_PARENT + ? values.parentId + : undefined, + latitude: values.latitude || undefined, + longitude: values.longitude || undefined, + area: values.area || undefined, + boundaryJson: values.boundaryJson + ? (JSON.parse(values.boundaryJson) as Record) + : undefined, + }; + + if (mode === "create") { + createLocation(payload, { + onSuccess: () => { + form.reset(); + onSuccess?.(); + }, + }); + return; + } + if (location) { + updateLocation( + { id: location.id, payload }, + { onSuccess: () => onSuccess?.() }, + ); + } + }; + + return ( +
+ +
+ ( + + Amharic Name * + + + + + + )} + /> + ( + + English Name + + + + + + )} + /> + ( + + Code * + + + + + + )} + /> + ( + + Location Type * + + + + )} + /> + ( + + Parent Location + + + + )} + /> +
+ +
+ Coordinates + {GOOGLE_MAPS_API_KEY ? ( +
+ + + {pin ? : null} + + +
+ ) : ( + // Name the missing variable rather than rendering a dead grey box. +

+ Map picker unavailable — VITE_GOOGLE_MAPS_API_KEY is + not set. Type the coordinates below instead. +

+ )} + {GOOGLE_MAPS_API_KEY ? ( +

+ Click the map to drop a pin, or type the values. +

+ ) : null} +
+ +
+ ( + + Latitude + + + + + + )} + /> + ( + + Longitude + + + + + + )} + /> + ( + + Area + + + + + + )} + /> +
+ + ( + + Boundary (GeoJSON) + +