From aa850146dcf25c810f3e2bb2883299fc9a269635 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 14 Aug 2026 08:22:50 +0300 Subject: [PATCH 01/28] Fix price on voucher --- .../prisma/fix-payment-method-currency.ts | 49 +++++++++++++++++++ .../backoffice/src/app/payments/page.tsx | 20 ++++++-- .../portal/src/lib/generate-voucher.ts | 47 +++++++++++++----- 3 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/fix-payment-method-currency.ts diff --git a/apps/edr-passenger-api/prisma/fix-payment-method-currency.ts b/apps/edr-passenger-api/prisma/fix-payment-method-currency.ts new file mode 100644 index 000000000..8273cb1c2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/fix-payment-method-currency.ts @@ -0,0 +1,49 @@ +/** + * One-off data fix: corrects PaymentMethod.currency for methods whose settlement currency + * was never set at seed time and silently defaulted to the schema's ETB default. + * + * payments.service.ts's chargeCurrency resolution reads this column directly (see the + * comment above `chargeCurrency` in `initiatePayment`): WAAFI settles in DJF, CARD in USD. + * With WAAFI stuck on the ETB default, live Waafi payments were charged in ETB instead of + * being converted to DJF — not just a mislabeled report. This script only touches the + * PaymentMethod config row; it does NOT rewrite any existing PaymentIntent/Booking records, + * since correcting historical transaction currency is a financial decision, not a data-fix + * this script should make unilaterally. + * + * Safe to re-run. Only updates rows that already exist; does not create new ones. + * + * Usage: node --env-file=.env -r ts-node/register prisma/fix-payment-method-currency.ts + */ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const CORRECTIONS: { type: string; currency: string }[] = [ + { type: 'WAAFI', currency: 'DJF' }, + { type: 'CARD', currency: 'USD' }, +]; + +async function main() { + for (const { type, currency } of CORRECTIONS) { + const existing = await prisma.paymentMethod.findUnique({ where: { type: type as any } }); + if (!existing) { + console.log(` ⚠️ No PaymentMethod row for ${type} — skipping (nothing to correct).`); + continue; + } + if (existing.currency === currency) { + console.log(` ℹ️ ${type} already set to ${currency} — no change.`); + continue; + } + await prisma.paymentMethod.update({ where: { type: type as any }, data: { currency } }); + console.log(` ✅ ${type}: ${existing.currency} → ${currency}`); + } +} + +main() + .catch((e) => { + console.error('❌ fix-payment-method-currency failed:', e); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx index e59a867ed..0bef617c1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import { Suspense, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw, X } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; @@ -56,18 +56,28 @@ const SectionHeader = ({ title }: { title: string }) => ( ); function PaymentsPageContent() { + const router = useRouter(); const searchParams = useSearchParams(); // A link can pre-filter this page — the dashboard's Revenue card links here with // status=SUCCEEDED&bookingStatus=CONFIRMED,BOARDED so "view payments" shows exactly the // payments that make up that revenue figure, not every payment attempt. - const initialBookingStatus = searchParams.get('bookingStatus') ?? ''; const [pageTab, setPageTab] = useState('payments'); const [filters, setFilters] = useState({ search: '', status: searchParams.get('status') ?? '', method: '', - bookingStatus: initialBookingStatus, + bookingStatus: searchParams.get('bookingStatus') ?? '', }); + + // useState's initializer only runs on first mount — if this page was already mounted from + // an earlier visit (e.g. the sidebar link), Next's client-side navigation to a new + // ?status=...&bookingStatus=... URL does NOT remount the component, so the filters above + // would silently keep whatever was set before. Re-sync whenever the URL itself changes. + useEffect(() => { + const status = searchParams.get('status') ?? ''; + const bookingStatus = searchParams.get('bookingStatus') ?? ''; + setFilters((f) => (f.status === status && f.bookingStatus === bookingStatus ? f : { ...f, status, bookingStatus })); + }, [searchParams]); const [selectedPayment, setSelectedPayment] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [paymentToDelete, setPaymentToDelete] = useState(null); @@ -319,7 +329,7 @@ function PaymentsPageContent() { @@ -629,6 +655,19 @@ export default function ContainerReturnsPage() { loading={createReturnsMutation.isPending} /> + setAllocateRow(null)} + onSubmit={(reference) => + allocateRow && + advanceStatusMutation.mutate({ + id: allocateRow.id, + wagonAllocationReference: reference, + }) + } + loading={advanceStatusMutation.isPending} + /> + setHistoryRow(null)} title="Status History" size="sm"> {historyRow && ( @@ -649,6 +688,123 @@ export default function ContainerReturnsPage() { ); } +/** + * Empties ride an EXPORT departure back to Djibouti, so wagon allocation picks + * from the export schedules that have not left yet (DRAFT/SCHEDULED). The pick + * is recorded as the return's `wagonAllocationReference`. + */ +function ExportTrainAllocationModal({ + row, + onClose, + onSubmit, + loading, +}: { + row: EmptyContainerReturn | null; + onClose: () => void; + onSubmit: (reference: string) => void; + loading: boolean; +}) { + const [scheduleId, setScheduleId] = useState(null); + + const schedulesQuery = useQuery( + api.trainScheduling.scheduleList.queryOptions({ + input: { filters: { pageSize: 100, sortBy: "scheduledDepartureDate", sortOrder: "ASC" } }, + enabled: Boolean(row), + }), + ); + + const exportTrains = useMemo( + () => + ((schedulesQuery.data?.items ?? []) as TrainScheduleListItem[]).filter( + (s) => s.direction === "EXPORT" && (s.status === "DRAFT" || s.status === "SCHEDULED"), + ), + [schedulesQuery.data], + ); + + const referenceOf = (train: TrainScheduleListItem) => + train.trainNumber || train.reference || train.id; + + return ( + + {row && ( + + {row.containerNumber} + + {schedulesQuery.isLoading ? ( + + + + ) : exportTrains.length === 0 ? ( + No export train is scheduled — create one in Train Scheduling. + ) : ( + + + + + Train + Departure + Route + Wagons + Status + + + + {exportTrains.map((train) => ( + setScheduleId(train.id)} + style={{ cursor: "pointer" }} + > + + setScheduleId(train.id)} + /> + + + + {referenceOf(train)} + + + + {train.scheduleDate ? new Date(train.scheduleDate).toLocaleDateString() : "—"} + + + {train.origin ?? "—"} → {train.destination ?? "—"} + + + {train.wagonsUsed ?? 0}/{train.wagonCount} + + + {train.status} + + + ))} + +
+ )} + + + + + +
+ )} +
+ ); +} + interface ContainerReturnModalProps { opened: boolean; onClose: () => void; From b4f2ec1c75c32665fa89daaab3b1cb87125eb9af Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 14 Aug 2026 09:57:20 +0000 Subject: [PATCH 05/28] feat(train-scheduling): load returned empties onto an export train Empties had no way onto a departure: the return record could name a train but nothing seated it on a wagon. Export schedules now expose a loading action that packs selected returns onto free wagons at one 40ft or two 20ft each, enforced both in the picker and in the API (existing empties on the schedule count against their wagon). Adds container_size, train_schedule_id and wagon_sequence_no to freight.empty_container_returns. --- .../3540000000000-EmptyReturnTrainLoad.ts | 35 +++ .../dto/import-operations.dto.ts | 55 +++- .../empty-container-wagon.util.spec.ts | 23 ++ .../empty-container-wagon.util.ts | 16 ++ .../entities/empty-container-return.entity.ts | 11 + .../import-operations.controller.ts | 9 + .../import-operations.service.ts | 62 ++++- .../LoadEmptyContainersModal.tsx | 249 ++++++++++++++++++ .../emptyContainerLoad.util.spec.ts | 60 +++++ .../emptyContainerLoad.util.ts | 54 ++++ .../backoffice/src/constants/URLS.ts | 2 + .../TrainScheduleV2DetailPage.tsx | 22 ++ .../pages/warehouses/ContainerReturnsPage.tsx | 5 + .../src/services/importOperations.service.ts | 11 + .../backoffice/src/types/importOperations.ts | 18 ++ 15 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/LoadEmptyContainersModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.ts diff --git a/apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts b/apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts new file mode 100644 index 000000000..55ef08262 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Empty containers ride an export departure back to Djibouti, so a return now + * records which train schedule carries it and on which wagon slot. Size is + * captured too: the wagon rule is one 40ft OR two 20ft per wagon, which cannot + * be enforced without knowing the box size. + */ +export class EmptyReturnTrainLoad3540000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + ADD COLUMN IF NOT EXISTS container_size character varying(10), + ADD COLUMN IF NOT EXISTS train_schedule_id uuid, + ADD COLUMN IF NOT EXISTS wagon_sequence_no integer + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_container_returns_train_schedule_id + ON freight.empty_container_returns (train_schedule_id) + WHERE train_schedule_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_empty_container_returns_train_schedule_id + `); + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS container_size, + DROP COLUMN IF EXISTS train_schedule_id, + DROP COLUMN IF EXISTS wagon_sequence_no + `); + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts index b3ec979b4..9af97f1e8 100644 --- a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts +++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts @@ -1,5 +1,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; import { DJIBOUTI_INCIDENT_TYPES, type DjiboutiIncidentType } from '../entities/djibouti-incident.entity'; import { @@ -120,6 +132,9 @@ export class ImportOperationActionDto { notes?: string; } +export const EMPTY_CONTAINER_SIZES = ['20', '40'] as const; +export type EmptyContainerSize = (typeof EMPTY_CONTAINER_SIZES)[number]; + export class CreateEmptyContainerReturnDto { @ApiProperty() @IsString() @@ -140,6 +155,11 @@ export class CreateEmptyContainerReturnDto { @IsDateString() returnDate?: string; + @ApiPropertyOptional({ enum: EMPTY_CONTAINER_SIZES }) + @IsOptional() + @IsIn(EMPTY_CONTAINER_SIZES) + containerSize?: EmptyContainerSize; + @ApiPropertyOptional() @IsOptional() @IsString() @@ -176,6 +196,39 @@ export class CreateEmptyContainerReturnDto { returnedBy?: 'EDR' | 'CUSTOMER'; } +export class LoadEmptyContainerItemDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + id!: string; + + @ApiProperty({ enum: EMPTY_CONTAINER_SIZES }) + @IsIn(EMPTY_CONTAINER_SIZES) + containerSize!: EmptyContainerSize; + + @ApiProperty() + @IsInt() + @Min(1) + wagonSequenceNo!: number; +} + +export class LoadEmptyContainersOnTrainDto extends ImportOperationActionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + trainScheduleId!: string; + + @ApiPropertyOptional({ description: 'Run number shown on the return record.' }) + @IsOptional() + @IsString() + trainNumber?: string; + + @ApiProperty({ type: [LoadEmptyContainerItemDto] }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => LoadEmptyContainerItemDto) + items!: LoadEmptyContainerItemDto[]; +} + export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto { @ApiProperty({ enum: EMPTY_CONTAINER_RETURN_STATUSES }) @IsIn(EMPTY_CONTAINER_RETURN_STATUSES) diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.spec.ts b/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.spec.ts new file mode 100644 index 000000000..34fcece4c --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.spec.ts @@ -0,0 +1,23 @@ +import { assertWagonLoad } from './empty-container-wagon.util'; + +describe('assertWagonLoad', () => { + it('accepts one 40ft or two 20ft per wagon', () => { + expect(() => + assertWagonLoad( + new Map([ + [1, ['40']], + [2, ['20', '20']], + [3, ['20']], + ]), + ), + ).not.toThrow(); + }); + + it('rejects a 40ft sharing a wagon', () => { + expect(() => assertWagonLoad(new Map([[4, ['40', '20']]]))).toThrow(/Wagon 4/); + }); + + it('rejects three containers on a wagon', () => { + expect(() => assertWagonLoad(new Map([[5, ['20', '20', '20']]]))).toThrow(/Wagon 5/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.ts b/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.ts new file mode 100644 index 000000000..bc82db3ee --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-container-wagon.util.ts @@ -0,0 +1,16 @@ +import { BadRequestException } from '@nestjs/common'; + +/** + * A wagon carries ONE 40ft OR TWO 20ft empties — never a mix, never three. + * Throws on the first wagon that breaks the rule. + */ +export function assertWagonLoad(sizesByWagon: Map): void { + for (const [wagon, sizes] of sizesByWagon) { + const has40 = sizes.some((size) => size === '40'); + if ((has40 && sizes.length > 1) || sizes.length > 2) { + throw new BadRequestException( + `Wagon ${wagon} takes one 40ft or two 20ft containers — got ${sizes.join('ft + ')}ft`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts index 2538a204b..263dd30d2 100644 --- a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -51,6 +51,17 @@ export class EmptyContainerReturn extends BaseEntity { @Column({ name: 'wagon_allocation_reference', type: 'varchar', length: 120, nullable: true }) wagonAllocationReference?: string | null; + /** '20' or '40' — drives the one-40ft-or-two-20ft-per-wagon loading rule. */ + @Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true }) + containerSize?: string | null; + + /** Export departure carrying this empty back to Djibouti. */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @Column({ name: 'wagon_sequence_no', type: 'int', nullable: true }) + wagonSequenceNo?: number | null; + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) performedBy?: string | null; diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index 15e5404a7..c631bef92 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -8,6 +8,7 @@ import { CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, + LoadEmptyContainersOnTrainDto, RecordDeclarationDto, UpdateEmptyContainerReturnStatusDto, UploadImportCustomsDocumentDto, @@ -104,6 +105,14 @@ export class ImportOperationsController { return this.service.createEmptyReturn(dto); } + @Post('empty-container-returns/load-on-train') + @ApiOperation({ + summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)', + }) + loadEmptyReturnsOnTrain(@Body() dto: LoadEmptyContainersOnTrainDto) { + return this.service.loadEmptyReturnsOnTrain(dto); + } + @Post('empty-container-returns/:id/status') @ApiOperation({ summary: 'Batch 16: advance empty container return workflow' }) updateEmptyReturnStatus( diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index bd40e2ab1..28eb4e44f 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -1,11 +1,12 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, + LoadEmptyContainersOnTrainDto, RecordDeclarationDto, AssignCustomsRiskDto, UpdateEmptyContainerReturnStatusDto, @@ -15,6 +16,7 @@ import { DjiboutiIncident, type DjiboutiIncidentType, } from './entities/djibouti-incident.entity'; +import { assertWagonLoad } from './empty-container-wagon.util'; import { EmptyContainerReturn } from './entities/empty-container-return.entity'; import { ImportCustomsFinalization, @@ -156,6 +158,7 @@ export class ImportOperationsService { bookingId: dto.bookingId ?? null, customerId: dto.customerId ?? null, returnDate, + containerSize: dto.containerSize ?? null, facility: dto.facility ?? null, yard: dto.yard ?? null, zone: dto.zone ?? null, @@ -170,6 +173,63 @@ export class ImportOperationsService { ); } + /** + * Load returned empties onto an export departure. A wagon takes ONE 40ft or + * TWO 20ft — never a mix, never three. Empties already sitting on a wagon of + * the same schedule count against that wagon, so incremental loads cannot + * quietly double-book a slot. + * + * ponytail: does not check the wagon is free of cargo bookings — the loading + * UI picks only unallocated wagons from the schedule's plan. Cross-check here + * if empties ever get loaded from another client. + */ + async loadEmptyReturnsOnTrain(dto: LoadEmptyContainersOnTrainDto) { + const ids = dto.items.map((item) => item.id); + const rows = await this.emptyReturns.find({ where: { id: In(ids) } }); + const missing = ids.filter((id) => !rows.some((row) => row.id === id)); + if (missing.length) { + throw new NotFoundException(`Empty container return(s) not found: ${missing.join(', ')}`); + } + + const alreadyOnTrain = await this.emptyReturns.find({ + where: { trainScheduleId: dto.trainScheduleId }, + }); + const byWagon = new Map(); + for (const row of alreadyOnTrain) { + if (row.wagonSequenceNo == null || ids.includes(row.id)) continue; + byWagon.set(row.wagonSequenceNo, [ + ...(byWagon.get(row.wagonSequenceNo) ?? []), + row.containerSize ?? '40', + ]); + } + for (const item of dto.items) { + byWagon.set(item.wagonSequenceNo, [ + ...(byWagon.get(item.wagonSequenceNo) ?? []), + item.containerSize, + ]); + } + assertWagonLoad(byWagon); + + const changedAt = new Date().toISOString(); + for (const item of dto.items) { + const row = rows.find((candidate) => candidate.id === item.id)!; + await this.emptyReturns.update(item.id, { + status: 'WAGON_ALLOCATED', + containerSize: item.containerSize, + trainScheduleId: dto.trainScheduleId, + wagonSequenceNo: item.wagonSequenceNo, + wagonAllocationReference: dto.trainNumber ?? dto.trainScheduleId, + performedBy: dto.performedBy ?? row.performedBy ?? null, + statusHistory: [ + ...(row.statusHistory ?? []), + { status: 'WAGON_ALLOCATED' as const, changedAt, performedBy: dto.performedBy ?? null }, + ], + }); + } + + return this.emptyReturns.find({ where: { trainScheduleId: dto.trainScheduleId } }); + } + async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) { const row = await this.emptyReturns.findOne({ where: { id } }); if (!row) { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LoadEmptyContainersModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LoadEmptyContainersModal.tsx new file mode 100644 index 000000000..eadd18f85 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LoadEmptyContainersModal.tsx @@ -0,0 +1,249 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Loader, + Modal, + SegmentedControl, + Stack, + Table, + Text, +} from "@mantine/core"; + +import { useToast } from "@/hooks/use-toast"; +import { importOperationsService } from "@/services/importOperations.service"; +import type { + EmptyContainerReturn, + EmptyContainerSize, +} from "@/types/importOperations"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { packEmptiesOntoWagons, wagonsNeeded } from "./emptyContainerLoad.util"; + +/** Empties still on the ground — past these the box has already left the yard. */ +const LOADABLE_STATUSES = ["RETURNED", "ASSIGNED_STORAGE", "DOCUMENTATION_CLEARED"]; + +interface LoadEmptyContainersModalProps { + opened: boolean; + onClose: () => void; + schedule: TrainScheduleDetail; +} + +/** + * Loads returned empty containers onto an export departure. Wagons are filled + * one 40ft OR two 20ft each (see `packEmptiesOntoWagons`), drawing only on + * wagons of this train that carry no cargo booking and no empty already. + */ +export function LoadEmptyContainersModal({ + opened, + onClose, + schedule, +}: LoadEmptyContainersModalProps) { + const { toast } = useToast(); + const qc = useQueryClient(); + const [selected, setSelected] = useState([]); + const [sizeOverrides, setSizeOverrides] = useState>({}); + + const returnsQuery = useQuery({ + queryKey: ["empty-container-returns"], + queryFn: () => importOperationsService.listEmptyReturns(), + enabled: opened, + }); + + const returns = returnsQuery.data ?? []; + const loaded = useMemo( + () => returns.filter((ret) => ret.trainScheduleId === schedule.id), + [returns, schedule.id], + ); + const available = useMemo( + () => + returns.filter( + (ret) => !ret.trainScheduleId && LOADABLE_STATUSES.includes(ret.status), + ), + [returns], + ); + + const sizeOf = (ret: EmptyContainerReturn): EmptyContainerSize => + sizeOverrides[ret.id] ?? (ret.containerSize === "20" ? "20" : "40"); + + // A wagon is up for grabs when no booking rides it and no empty sits on it. + const freeWagons = useMemo(() => { + const takenByEmpties = new Set( + loaded.map((ret) => ret.wagonSequenceNo).filter((no): no is number => no != null), + ); + return (schedule.trainSet?.wagons ?? []) + .filter((wagon) => !wagon.allocations?.length && !takenByEmpties.has(wagon.sequenceNo)) + .map((wagon) => wagon.sequenceNo) + .sort((a, b) => a - b); + }, [schedule.trainSet?.wagons, loaded]); + + const picks = useMemo( + () => + available + .filter((ret) => selected.includes(ret.id)) + .map((ret) => ({ id: ret.id, containerSize: sizeOf(ret) })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [available, selected, sizeOverrides], + ); + const needed = wagonsNeeded(picks); + const { assignments, unplaced } = packEmptiesOntoWagons(picks, freeWagons); + + const load = useMutation({ + mutationFn: () => + importOperationsService.loadEmptyContainersOnTrain({ + trainScheduleId: schedule.id, + trainNumber: schedule.trainNumber ?? undefined, + items: assignments, + }), + onSuccess: () => { + toast({ title: `${assignments.length} empty container(s) loaded` }); + qc.invalidateQueries({ queryKey: ["empty-container-returns"] }); + qc.invalidateQueries({ queryKey: ["train-scheduling"] }); + setSelected([]); + onClose(); + }, + onError: (error: any) => { + toast({ + variant: "destructive", + title: "Failed to load empty containers", + description: error?.response?.data?.message || error?.message, + }); + }, + }); + + return ( + + + + One 40ft or two 20ft containers per wagon. {freeWagons.length} free wagon + {freeWagons.length === 1 ? "" : "s"} on this train. + + + {loaded.length > 0 ? ( + + + {loaded.map((ret) => ( + + {ret.containerNumber} · wagon {ret.wagonSequenceNo ?? "—"} + + ))} + + + ) : null} + + {returnsQuery.isLoading ? ( + + + + ) : available.length === 0 ? ( + + No returned empty containers are waiting — record returns in Container Returns. + + ) : ( + + + + + + Container + Size + Facility + Returned + Status + + + + {available.map((ret) => { + const checked = selected.includes(ret.id); + return ( + + + + setSelected( + event.currentTarget.checked + ? [...selected, ret.id] + : selected.filter((id) => id !== ret.id), + ) + } + /> + + + + {ret.containerNumber} + + + + {/* Legacy returns carry no size — the operator sets it here + because the wagon rule cannot be applied without it. */} + + setSizeOverrides({ + ...sizeOverrides, + [ret.id]: value as EmptyContainerSize, + }) + } + data={[ + { label: "20ft", value: "20" }, + { label: "40ft", value: "40" }, + ]} + /> + + {ret.facility ?? "—"} + + {ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"} + + + + {ret.status} + + + + ); + })} + +
+
+ )} + + {unplaced.length > 0 ? ( + + {needed} wagon(s) needed but only {freeWagons.length} free — unselect{" "} + {unplaced.length} container(s) or add wagons to the consist. + + ) : picks.length > 0 ? ( + + {picks.length} container(s) → wagons{" "} + {[...new Set(assignments.map((a) => a.wagonSequenceNo))].join(", ")} + + ) : null} + + + + + +
+
+ ); +} + +export default LoadEmptyContainersModal; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.spec.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.spec.ts new file mode 100644 index 000000000..9094f3139 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { packEmptiesOntoWagons, wagonsNeeded, type EmptyLoadPick } from './emptyContainerLoad.util'; + +const pick = (id: string, containerSize: '20' | '40'): EmptyLoadPick => ({ id, containerSize }); + +describe('emptyContainerLoad.util', () => { + it('gives each 40ft its own wagon', () => { + const { assignments, unplaced } = packEmptiesOntoWagons( + [pick('a', '40'), pick('b', '40')], + [1, 2, 3], + ); + expect(unplaced).toEqual([]); + expect(assignments.map((a) => a.wagonSequenceNo)).toEqual([1, 2]); + }); + + it('pairs 20ft two to a wagon, last odd one alone', () => { + const { assignments } = packEmptiesOntoWagons( + [pick('a', '20'), pick('b', '20'), pick('c', '20')], + [4, 5], + ); + expect(assignments.map((a) => [a.id, a.wagonSequenceNo])).toEqual([ + ['a', 4], + ['b', 4], + ['c', 5], + ]); + }); + + it('never mixes a 40ft and a 20ft on one wagon', () => { + const { assignments } = packEmptiesOntoWagons( + [pick('a', '20'), pick('b', '40'), pick('c', '20')], + [1, 2], + ); + const bySizeOnWagon = new Map(); + for (const a of assignments) { + bySizeOnWagon.set(a.wagonSequenceNo, [ + ...(bySizeOnWagon.get(a.wagonSequenceNo) ?? []), + a.containerSize, + ]); + } + for (const sizes of bySizeOnWagon.values()) { + expect(sizes.includes('40') ? sizes.length : 0).toBeLessThan(2); + expect(sizes.length).toBeLessThanOrEqual(2); + } + }); + + it('reports picks that ran out of wagons instead of dropping them', () => { + const { assignments, unplaced } = packEmptiesOntoWagons( + [pick('a', '40'), pick('b', '40'), pick('c', '20'), pick('d', '20')], + [7], + ); + expect(assignments).toHaveLength(1); + expect(unplaced.map((p) => p.id)).toEqual(['b', 'c', 'd']); + }); + + it('counts wagons needed', () => { + expect(wagonsNeeded([])).toBe(0); + expect(wagonsNeeded([pick('a', '40'), pick('b', '20'), pick('c', '20')])).toBe(2); + expect(wagonsNeeded([pick('a', '20')])).toBe(1); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.ts new file mode 100644 index 000000000..588fe7649 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/emptyContainerLoad.util.ts @@ -0,0 +1,54 @@ +import type { EmptyContainerSize } from "@/types/importOperations"; + +export interface EmptyLoadPick { + id: string; + containerSize: EmptyContainerSize; +} + +export interface EmptyLoadAssignment extends EmptyLoadPick { + wagonSequenceNo: number; +} + +/** + * Fill wagons with the picked empties: a wagon takes ONE 40ft or TWO 20ft, + * never a mix. 40ft boxes are seated first so a half-filled 20ft wagon can + * never block them, and the 20s pair up behind them. + * + * `freeWagons` is the caller's ordered list of wagon sequence numbers with no + * cargo allocation. Returns the assignments that fit plus the picks that had + * no wagon left — the caller surfaces the shortfall instead of silently + * dropping boxes. + */ +export function packEmptiesOntoWagons( + picks: EmptyLoadPick[], + freeWagons: number[], +): { assignments: EmptyLoadAssignment[]; unplaced: EmptyLoadPick[] } { + const forty = picks.filter((pick) => pick.containerSize === "40"); + const twenty = picks.filter((pick) => pick.containerSize === "20"); + + const assignments: EmptyLoadAssignment[] = []; + const unplaced: EmptyLoadPick[] = []; + const wagons = [...freeWagons]; + + for (const pick of forty) { + const wagon = wagons.shift(); + if (wagon == null) unplaced.push(pick); + else assignments.push({ ...pick, wagonSequenceNo: wagon }); + } + + for (let index = 0; index < twenty.length; index += 2) { + const pair = twenty.slice(index, index + 2); + const wagon = wagons.shift(); + if (wagon == null) unplaced.push(...pair); + else assignments.push(...pair.map((pick) => ({ ...pick, wagonSequenceNo: wagon }))); + } + + return { assignments, unplaced }; +} + +/** Wagons the picks consume, whether or not enough are free. */ +export function wagonsNeeded(picks: EmptyLoadPick[]): number { + const forty = picks.filter((pick) => pick.containerSize === "40").length; + const twenty = picks.length - forty; + return forty + Math.ceil(twenty / 2); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index fd115e97d..2a9f40ebd 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -733,6 +733,8 @@ export const URL_CONSTANTS = { EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns", EMPTY_CONTAINER_RETURN_STATUS: (id: string) => `/import-operations/empty-container-returns/${id}/status`, + EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN: + "/import-operations/empty-container-returns/load-on-train", }, VEHICLES: { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index acfc3c375..ef9171d0d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -57,6 +57,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"; +import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal"; import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal"; import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; @@ -126,6 +127,7 @@ export default function TrainScheduleV2DetailPage() { const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false); const [switchTarget, setSwitchTarget] = useState(null); const [visualization3DOpen, setVisualization3DOpen] = useState(false); + const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false); const autoPreviewedRef = useRef(false); const detailQuery = useQuery( @@ -967,6 +969,20 @@ export default function TrainScheduleV2DetailPage() { Merge ) : null} + {/* Empties ride an export departure back to Djibouti — offered only + while the train can still take load. */} + {schedule.direction === "EXPORT" && + ["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( + + ) : null} {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( + + + setOpened(false)} /> + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx b/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx new file mode 100644 index 000000000..2dc6c1240 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx @@ -0,0 +1,82 @@ +import { useMemo, useState } from "react"; +import { Button, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core"; +import { Plus, Search } from "lucide-react"; + +import type { FilterDef } from "./types"; + +export interface MoreFiltersMenuProps { + defs: FilterDef[]; + /** Called with the picked def's key — the caller pins it and opens its popover. */ + onPick: (key: string) => void; +} + +/** Searchable list over the page's secondary/inactive filters. Plain filter + list, + * not cmdk — a handful of static strings doesn't need a Combobox store. */ +export function MoreFiltersMenu({ defs, onPick }: MoreFiltersMenuProps) { + const [opened, setOpened] = useState(false); + const [query, setQuery] = useState(""); + + const visible = useMemo( + () => defs.filter((d) => d.label.toLowerCase().includes(query.toLowerCase())), + [defs, query], + ); + + if (defs.length === 0) return null; + + return ( + + + + + + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + size="sm" + autoFocus + /> + + + {visible.map((d) => ( + { + setOpened(false); + setQuery(""); + onPick(d.key); + }} + > + + + {d.label} + + + ))} + {visible.length === 0 && ( + + No matching filters + + )} + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx b/apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx new file mode 100644 index 000000000..a769ebf93 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx @@ -0,0 +1,25 @@ +import { SegmentedControl } from "@mantine/core"; +import { DEFAULT_OP, OPERATOR_LABELS, type FilterDef, type Operator } from "./types"; + +export interface OperatorSelectProps { + def: FilterDef; + value: Operator; + onChange: (op: Operator) => void; +} + +/** Renders nothing when a def has <= 1 operator — most defs, by design: type-aware + * operators are a capability, not a dropdown forced into every popover. */ +export function OperatorSelect({ def, value, onChange }: OperatorSelectProps) { + const operators = def.operators ?? [DEFAULT_OP[def.type]]; + if (operators.length <= 1) return null; + return ( + onChange(v as Operator)} + data={operators.map((op) => ({ value: op, label: OPERATOR_LABELS[op] }))} + mb="xs" + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx new file mode 100644 index 000000000..826356159 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { ActionIcon, Button, Menu, Modal, Stack, Text, TextInput } from "@mantine/core"; +import { useLocalStorage } from "@mantine/hooks"; +import { Bookmark, Check, Save, Trash2 } from "lucide-react"; + +interface SavedView { + id: string; + name: string; + query: string; +} + +export interface SavedViewsProps { + /** localStorage namespace — one page, not one user (single staff login per + * browser profile). ponytail: add ":" if shared-terminal login appears. */ + viewId: string; + currentQueryString: () => string; + applyQueryString: (query: string) => void; +} + +/** URL always wins: this menu only ever WRITES the URL, on click. Nothing + * reads a saved view at mount, so a shared link always beats a saved view — + * there is no "which one applies" branch to get wrong. */ +export function SavedViews({ viewId, currentQueryString, applyQueryString }: SavedViewsProps) { + const [views, setViews] = useLocalStorage({ + key: `edr:saved-views:${viewId}`, + defaultValue: [], + }); + const [saveOpen, setSaveOpen] = useState(false); + const [name, setName] = useState(""); + + const activeQuery = currentQueryString(); + const active = views.find((v) => v.query === activeQuery); + + const save = () => { + if (!name.trim()) return; + setViews((prev) => [ + ...prev, + { id: crypto.randomUUID(), name: name.trim(), query: currentQueryString() }, + ]); + setName(""); + setSaveOpen(false); + }; + + const remove = (id: string) => setViews((prev) => prev.filter((v) => v.id !== id)); + + return ( + <> + + + + + + {views.length === 0 && ( + + + No saved views yet + + + )} + {views.map((v) => ( + : } + rightSection={ + { + e.stopPropagation(); + remove(v.id); + }} + > + + + } + onClick={() => applyQueryString(v.query)} + > + {v.name} + + ))} + + } onClick={() => setSaveOpen(true)}> + Save current view… + + + + + setSaveOpen(false)} title="Save current view" size="sm"> + + setName(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && save()} + autoFocus + /> + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx new file mode 100644 index 000000000..cdb9dde33 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx @@ -0,0 +1,40 @@ +import { Button, Menu } from "@mantine/core"; +import { ArrowUpDown, Check } from "lucide-react"; + +import type { SortOption } from "./types"; + +export interface SortControlProps { + options: SortOption[]; + value: string; + onChange: (value: string) => void; +} + +/** A control, not a form field — Menu (not Select) gives the check-mark + + * trigger-label read Stripe's sort control has. Rendered only when a page + * passes sortOptions; inventing options for an endpoint without sortBy + * support would ship a control that silently does nothing. */ +export function SortControl({ options, value, onChange }: SortControlProps) { + if (options.length === 0) return null; + const current = options.find((o) => o.value === value); + + return ( + + + + + + {options.map((o) => ( + : } + onClick={() => onChange(o.value)} + > + {o.label} + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx new file mode 100644 index 000000000..fc59fa3bb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { Button, Radio, Stack } from "@mantine/core"; + +import { DEFAULT_OP } from "../types"; +import type { BooleanFilterDef, Operator } from "../types"; +import { OperatorSelect } from "../OperatorSelect"; +import type { FilterBodyProps } from "./TextBody"; + +export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps) { + const [op, setOp] = useState(value?.op ?? DEFAULT_OP.boolean); + const [v, setV] = useState(value?.v[0] ?? ""); + + const apply = () => { + onChange(v ? { op, v: [v] } : undefined); + onClose(); + }; + + return ( + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx new file mode 100644 index 000000000..a7c90c8c9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { Button, Stack } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; + +import { getDateRangePresets } from "@/components/common/dateRangePresets"; +import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates"; +import { DEFAULT_OP } from "../types"; +import type { DateFilterDef, Operator } from "../types"; +import { OperatorSelect } from "../OperatorSelect"; +import type { FilterBodyProps } from "./TextBody"; + +// ponytail: Gregorian only. Record-management pages need the Ethiopian +// calendar (see shared/common/form/fields/AmharicDatePicker.tsx) — add an +// i18n.language !== "en" branch here when this body is first wired into a +// record-management page (Phase 4 of the filter-bar rollout). +export function DateBody({ def, value, onChange, onClose }: FilterBodyProps) { + const [op, setOp] = useState(value?.op ?? DEFAULT_OP.date); + // Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects. + const [from, setFrom] = useState(value?.v[0]?.slice(0, 10) ?? null); + const [to, setTo] = useState(value?.v[1]?.slice(0, 10) ?? null); + + const apply = () => { + if (op === "between") { + onChange( + from && to + ? { op, v: [startOfDayIso(parseDateStr(from)), endOfDayIso(parseDateStr(to))] } + : undefined, + ); + } else { + onChange( + from + ? { + op, + v: [ + op === "before" + ? startOfDayIso(parseDateStr(from)) + : endOfDayIso(parseDateStr(from)), + ], + } + : undefined, + ); + } + onClose(); + }; + + // This popover already lives inside FilterPill's own Popover. Mantine's + // DatePickerInput opens ITS calendar in a separate portal by default, so a + // click on a day registers as "outside" the outer Popover and closes the + // whole filter before the range can be picked (or Apply reached) — the + // reported "date picker doesn't work". Keeping the calendar un-portalled + // renders it inside the outer popover's own DOM subtree instead, so + // outside-click detection sees it as inside. + const nestedPopoverProps = { withinPortal: false } as const; + + return ( + + + {op === "between" ? ( + { + setFrom(f); + setTo(t); + }} + presets={getDateRangePresets()} + popoverProps={nestedPopoverProps} + clearable + autoFocus + /> + ) : ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx new file mode 100644 index 000000000..459246dff --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx @@ -0,0 +1,128 @@ +import { useMemo, useState } from "react"; +import { Button, Checkbox, Group, Radio, Stack, Text, TextInput, UnstyledButton } from "@mantine/core"; +import { Search } from "lucide-react"; + +import { DEFAULT_OP } from "../types"; +import type { EnumFilterDef, Operator } from "../types"; +import { OperatorSelect } from "../OperatorSelect"; +import type { FilterBodyProps } from "./TextBody"; + +/** How many options before a search box appears above the list. */ +const SEARCH_THRESHOLD = 8; + +/** + * Stretches the Checkbox/Radio's native
+ + {showEmpty ? ( @@ -791,18 +493,7 @@ export default function BookingRequestsPage() { data={rows} status={isLoading ? "loading" : isError ? "error" : "success"} onRowClick={handleRowClick} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - manualPagination: true, - pageCount, - }} + {...controls.tableProps(total)} containerClassName="border-0 shadow-none bg-transparent" footer={DataTableFooter} /> From e5e75b4166ba981bd37388380a023d08ddc6ffe6 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 14 Aug 2026 14:13:11 +0000 Subject: [PATCH 13/28] feat(filter-bar): migrate 3 more fleet pages, extract footer adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruleEngineFooterProps.ts: toRuleEngineFooterProps() — the useFilters-to-RuleEngineListFooter pagination adapter had already been hand-written twice (TrucksOnSitePage, CompliancePage) with the same pageSize-drop risk useFilters itself just had fixed; extracted before a third copy could drift. - CompliancePage, FuelPurchasePage, IncidentsPage: ListControls -> FilterBar + applyClientFilters, same mechanical pattern as the warehouse pages (search + one date range, endpoints take no params at all per the inventory sweep — confirmed correct bucket, not assumed). --- .../src/components/filters/index.ts | 1 + .../filters/ruleEngineFooterProps.ts | 32 +++++++++++++ .../src/pages/fleet/CompliancePage.tsx | 48 +++++++++++-------- .../src/pages/fleet/FuelPurchasePage.tsx | 48 +++++++++++-------- .../src/pages/fleet/IncidentsPage.tsx | 48 +++++++++++-------- .../src/pages/warehouses/TrucksOnSitePage.tsx | 18 ++++--- 6 files changed, 122 insertions(+), 73 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/ruleEngineFooterProps.ts diff --git a/apps/edr-freight-web/backoffice/src/components/filters/index.ts b/apps/edr-freight-web/backoffice/src/components/filters/index.ts index 5fe7702f3..ea2c28fb2 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/index.ts @@ -3,6 +3,7 @@ export * from "./url"; export * from "./dates"; export * from "./format"; export * from "./clientFilter"; +export * from "./ruleEngineFooterProps"; export * from "./useFilters"; export * from "./useSavedViews"; export { FilterBar } from "./FilterBar"; diff --git a/apps/edr-freight-web/backoffice/src/components/filters/ruleEngineFooterProps.ts b/apps/edr-freight-web/backoffice/src/components/filters/ruleEngineFooterProps.ts new file mode 100644 index 000000000..e8cd32dab --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/ruleEngineFooterProps.ts @@ -0,0 +1,32 @@ +import type { OnChangeFn, PaginationState } from "@edr/ui-common"; +import type { UseFilters } from "./useFilters"; + +/** + * Adapts `useFilters`'s URL-backed page/pageSize to `RuleEngineListFooter`'s + * prop shape, for the client-bridge pages that render a plain `` + + * that footer instead of `` (which has `tableProps()` for this). + * Routes page-index vs page-size changes to the right setter — the same + * pageSize-gets-silently-dropped bug `tableProps()` had before it was fixed. + */ +export function toRuleEngineFooterProps( + controls: Pick, + totalCount: number, +): { + pagination: PaginationState; + pageCount: number; + totalCount: number; + onPaginationChange: OnChangeFn; +} { + const { page, pageSize, setPage, setPageSize } = controls; + return { + pagination: { pageIndex: page - 1, pageSize }, + pageCount: Math.max(1, Math.ceil(totalCount / pageSize)), + totalCount, + onPaginationChange: (updater) => { + const current = { pageIndex: page - 1, pageSize }; + const next = typeof updater === "function" ? updater(current) : updater; + if (next.pageSize !== pageSize) setPageSize(next.pageSize); + else if (next.pageIndex !== current.pageIndex) setPage(next.pageIndex + 1); + }, + }; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx index f31bf5e76..9cb305bbb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx @@ -18,11 +18,16 @@ import { } from "@mantine/core"; import { Plus, AlertTriangle } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import ListControls from "@/components/common/ListControls"; // Generic list footer — already shared by the fleet and train-scheduling lists // despite the ruleEngine path. import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; -import { useListControls } from "@/hooks/useListControls"; +import { + applyClientFilters, + FilterBar, + toRuleEngineFooterProps, + useFilters, + type FilterDef, +} from "@/components/filters"; import { useToast } from "@/hooks/use-toast"; import { complianceService, @@ -52,6 +57,8 @@ const statusColor = (status: ComplianceRecord["status"]) => { const formatDate = (value?: string | null) => value ? new Date(value).toLocaleDateString() : "—"; +const COMPLIANCE_FILTER_DEFS: FilterDef[] = [{ key: "expiryDate", label: "Expiry", type: "date" }]; + const emptyForm = { vehicleId: "", type: "INSPECTION" as ComplianceType, @@ -91,10 +98,18 @@ export default function CompliancePage() { }, }); - const controls = useListControls(records as ComplianceRecord[], { - searchKeys: ["type", "status", "documentNumber"], - dateKey: "expiryDate", - }); + const controls = useFilters(COMPLIANCE_FILTER_DEFS, { pageSize: 10 }); + const filteredRecords = applyClientFilters( + records as ComplianceRecord[], + COMPLIANCE_FILTER_DEFS, + controls.values, + controls.searchText, + { searchKeys: ["type", "status", "documentNumber"] }, + ); + const pagedRecords = filteredRecords.slice( + (controls.page - 1) * controls.pageSize, + controls.page * controls.pageSize, + ); const createMutation = useMutation({ mutationFn: async (data: typeof formData) => { @@ -220,17 +235,11 @@ export default function CompliancePage() { Compliance Records -
@@ -261,7 +270,7 @@ export default function CompliancePage() { ) : null} - {controls.pagedRows.map((record) => ( + {pagedRecords.map((record) => ( {vehicleLabel(record)} @@ -282,11 +291,8 @@ export default function CompliancePage() {
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index 8ecedd712..ec9c93482 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -19,11 +19,16 @@ import { } from "@mantine/core"; import { Plus } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import ListControls from "@/components/common/ListControls"; // Generic list footer — already shared by the fleet and train-scheduling lists // despite the ruleEngine path. import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; -import { useListControls } from "@/hooks/useListControls"; +import { + applyClientFilters, + FilterBar, + toRuleEngineFooterProps, + useFilters, + type FilterDef, +} from "@/components/filters"; import { useToast } from "@/hooks/use-toast"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; @@ -45,6 +50,8 @@ interface FuelPurchase { } +const FUEL_FILTER_DEFS: FilterDef[] = [{ key: "purchaseDate", label: "Purchased", type: "date" }]; + export default function FuelPurchasePage() { const { toast } = useToast(); const qc = useQueryClient(); @@ -123,10 +130,18 @@ export default function FuelPurchasePage() { const totalCost = formData.liters * formData.costPerLiter; // Aggregate stats (guarded against divide-by-zero when there are no purchases) - const controls = useListControls(purchasesData as FuelPurchase[], { - searchKeys: ["fuelStation", "paymentMethod"], - dateKey: "purchaseDate", - }); + const controls = useFilters(FUEL_FILTER_DEFS, { pageSize: 10 }); + const filteredPurchases = applyClientFilters( + purchasesData as FuelPurchase[], + FUEL_FILTER_DEFS, + controls.values, + controls.searchText, + { searchKeys: ["fuelStation", "paymentMethod"] }, + ); + const pagedPurchases = filteredPurchases.slice( + (controls.page - 1) * controls.pageSize, + controls.page * controls.pageSize, + ); const totalLiters = (purchasesData as FuelPurchase[]).reduce( (sum, p) => sum + Number(p.liters), @@ -195,17 +210,11 @@ export default function FuelPurchasePage() { {/* Purchases Table */} - @@ -237,7 +246,7 @@ export default function FuelPurchasePage() { ) : null} - {controls.pagedRows.map((purchase) => ( + {pagedPurchases.map((purchase) => ( {(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId} {new Date(purchase.purchaseDate).toLocaleDateString()} @@ -253,11 +262,8 @@ export default function FuelPurchasePage() {
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx index 6276091b8..62c3d9e01 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx @@ -20,11 +20,16 @@ import { } from "@mantine/core"; import { Plus } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import ListControls from "@/components/common/ListControls"; // Generic list footer — already shared by the fleet and train-scheduling lists // despite the ruleEngine path. import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; -import { useListControls } from "@/hooks/useListControls"; +import { + applyClientFilters, + FilterBar, + toRuleEngineFooterProps, + useFilters, + type FilterDef, +} from "@/components/filters"; import { useToast } from "@/hooks/use-toast"; import { incidentsService, @@ -89,6 +94,8 @@ const initialForm = { reportedBy: "", }; +const INCIDENT_FILTER_DEFS: FilterDef[] = [{ key: "occurredAt", label: "Occurred", type: "date" }]; + export default function IncidentsPage() { const { toast } = useToast(); const qc = useQueryClient(); @@ -166,10 +173,18 @@ export default function IncidentsPage() { })) || []; const incidents = incidentsData as Incident[]; - const controls = useListControls(incidents, { - searchKeys: ["type", "severity", "status"], - dateKey: "occurredAt", - }); + const controls = useFilters(INCIDENT_FILTER_DEFS, { pageSize: 10 }); + const filteredIncidents = applyClientFilters( + incidents, + INCIDENT_FILTER_DEFS, + controls.values, + controls.searchText, + { searchKeys: ["type", "severity", "status"] }, + ); + const pagedIncidents = filteredIncidents.slice( + (controls.page - 1) * controls.pageSize, + controls.page * controls.pageSize, + ); const totalCount = incidents.length; const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length; const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length; @@ -246,17 +261,11 @@ export default function IncidentsPage() { {/* Incidents Table */} - @@ -288,7 +297,7 @@ export default function IncidentsPage() { ) : null} - {controls.pagedRows.map((incident) => ( + {pagedIncidents.map((incident) => ( {new Date(incident.occurredAt).toLocaleDateString()} @@ -316,11 +325,8 @@ export default function IncidentsPage() {
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx index 39f7daac4..7ddcef866 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx @@ -14,7 +14,13 @@ import { PageContainer, PageHeader } from "@/components/page"; // Generic list footer — already shared by the fleet and train-scheduling lists // despite the ruleEngine path; reused here rather than adding a second one. import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; -import { applyClientFilters, FilterBar, useFilters, type FilterDef } from "@/components/filters"; +import { + applyClientFilters, + FilterBar, + toRuleEngineFooterProps, + useFilters, + type FilterDef, +} from "@/components/filters"; import { useTrucksOnSite } from "@/hooks/useWarehouses"; import type { TruckOnSite } from "@/types/warehouse"; import { formatDateTime } from "@/lib/format"; @@ -231,16 +237,8 @@ export default function TrucksOnSitePage() { <> { - const current = { pageIndex: controls.page - 1, pageSize: controls.pageSize }; - const next = typeof updater === "function" ? updater(current) : updater; - if (next.pageSize !== controls.pageSize) controls.setPageSize(next.pageSize); - else controls.setPage(next.pageIndex + 1); - }} + {...toRuleEngineFooterProps(controls, filteredTrucks.length)} /> )} From 839dfdbae36bc39f87c7bf01a3d9bd595ae4876e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 07:23:43 +0000 Subject: [PATCH 14/28] feat(filters): route filter, select-styled trigger, tune default pin set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New "route" FilterType: RouteBody popover (searchable origin + destination selects, apply once both are picked), wired into ContractRequestsPage and BookingRequestsPage. Bookings already had server-side originYardId/destinationYardId; contracts gets both new (contract_routes is one-to-many, so origin/destination are separate EXISTS subqueries, not a join). - Inactive FilterPill trigger restyled to read like a closed Mantine Select (opaque solid border, trailing chevron) instead of a dashed "+" pill — trigger only, popover body/position unchanged. - Search box text set to regular weight. - A couple more filters (Direction, Freight) pinned by default per page on top of the existing always-pinned ones; the rest stay behind More filters. --- .../modules/contracts/contracts.repository.ts | 21 ++++++++ .../modules/contracts/contracts.service.ts | 4 ++ .../contracts/dto/filter-contract.dto.ts | 16 ++++++ .../src/components/filters/FilterBar.tsx | 1 + .../src/components/filters/FilterPill.tsx | 28 +++++++---- .../components/filters/bodies/RouteBody.tsx | 50 +++++++++++++++++++ .../src/components/filters/format.ts | 4 ++ .../src/components/filters/types.ts | 18 ++++++- .../pages/bookings/BookingRequestsPage.tsx | 10 ++-- .../pages/contracts/ContractRequestsPage.tsx | 24 +++++++-- 10 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index f618986c4..d89d43250 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -48,6 +48,8 @@ export interface ContractListFilterOptions { hasClearanceDocuments?: boolean; createdFrom?: string; createdTo?: string; + originYardId?: string; + destinationYardId?: string; } @Injectable() @@ -489,6 +491,25 @@ export class ContractsRepository extends BaseRepository { if (options.createdTo) { qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo }); } + // Routes are one-to-many (a contract can list several lanes), so origin + // and destination each need their own EXISTS — a plain join would + // duplicate the contract row per matching route. + if (omit !== 'originYardId' && options.originYardId) { + qb.andWhere( + 'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' + + 'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' + + 'AND cr_o.origin_yard_id = :originYardId)', + { originYardId: options.originYardId }, + ); + } + if (omit !== 'destinationYardId' && options.destinationYardId) { + qb.andWhere( + 'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' + + 'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' + + 'AND cr_d.destination_yard_id = :destinationYardId)', + { destinationYardId: options.destinationYardId }, + ); + } } /** diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 5798d44ef..3411414e9 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -768,6 +768,8 @@ export class ContractsService { paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, + originYardId: filter.originYardId, + destinationYardId: filter.destinationYardId, search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -789,6 +791,8 @@ export class ContractsService { paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, + originYardId: filter.originYardId, + destinationYardId: filter.destinationYardId, }; const [facets, metrics] = await Promise.all([ diff --git a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts index 8950e7eb3..a03d52afb 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts @@ -61,6 +61,22 @@ export class FilterContractDto { @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: 'Only contracts with a route starting at this yard.', + }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Only contracts with a route ending at this yard.', + }) + @IsOptional() + @IsUUID() + destinationYardId?: string; + @ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' }) @IsOptional() @IsDateString() diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx index a712d189e..6a0aaac96 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx @@ -84,6 +84,7 @@ export function FilterBar({ onChange={(e) => controls.setSearchText(e.currentTarget.value)} size="xs" radius="lg" + styles={{ input: { fontWeight: 400 } }} style={{ minWidth: 160, flex: "1 1 160px" }} /> )} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx index 7a4a76998..0275f67ad 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { ActionIcon, Button, Popover } from "@mantine/core"; -import { Plus, X } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; import type { FilterDef, FilterValue } from "./types"; import { formatFilterValue } from "./format"; @@ -8,6 +8,7 @@ import { BooleanBody } from "./bodies/BooleanBody"; import { DateBody } from "./bodies/DateBody"; import { EnumBody } from "./bodies/EnumBody"; import { NumberBody } from "./bodies/NumberBody"; +import { RouteBody } from "./bodies/RouteBody"; import { TextBody } from "./bodies/TextBody"; const BODIES: Record> = { @@ -16,6 +17,7 @@ const BODIES: Record> = { date: DateBody, number: NumberBody, boolean: BooleanBody, + route: RouteBody, }; export interface FilterPillProps { @@ -37,18 +39,22 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/format.ts b/apps/edr-freight-web/backoffice/src/components/filters/format.ts index c3ca16798..24489abf8 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/format.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/format.ts @@ -13,6 +13,10 @@ export function formatFilterValue(def: FilterDef, value: FilterValue): string { if (def.type === "date" && value.v.length === 2) { return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`; } + if (def.type === "route" && value.v.length === 2) { + const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id; + return `${label(value.v[0])} → ${label(value.v[1])}`; + } return value.v.join(", "); } diff --git a/apps/edr-freight-web/backoffice/src/components/filters/types.ts b/apps/edr-freight-web/backoffice/src/components/filters/types.ts index 597d74f6f..9f0c35d43 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/types.ts @@ -1,4 +1,4 @@ -export type FilterType = "text" | "enum" | "date" | "number" | "boolean"; +export type FilterType = "text" | "enum" | "date" | "number" | "boolean" | "route"; export type Operator = "is" | "isNot" | "contains" | "between" | "before" | "after"; @@ -9,6 +9,7 @@ export const DEFAULT_OP: Record = { date: "between", number: "is", boolean: "is", + route: "is", }; export const OPERATOR_LABELS: Record = { @@ -87,12 +88,25 @@ export interface BooleanFilterDef extends FilterDefBase { falseLabel?: string; } +/** + * Origin + destination picked together as one pill — `v` is always the + * 2-slot pair `[originYardId, destinationYardId]`, never partial (the body's + * Apply button stays disabled until both sides are chosen, same rule + * `DateBody` uses for a `between` range). One shared `options` list drives + * both selects. + */ +export interface RouteFilterDef extends FilterDefBase { + type: "route"; + options: FilterOption[]; +} + export type FilterDef = | TextFilterDef | EnumFilterDef | DateFilterDef | NumberFilterDef - | BooleanFilterDef; + | BooleanFilterDef + | RouteFilterDef; /** A page's sort options — value is already `"field:DIR"`, the codebase's existing convention. */ export interface SortOption { 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 cef8ca21a..c1b5c85ac 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -136,9 +136,9 @@ export default function BookingRequestsPage() { { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { key: "tradeDirection", label: "Direction", type: "enum", multiple: false, - options: filterOptions(TRADE_DIRECTION_OPTIONS), secondary: true, + options: filterOptions(TRADE_DIRECTION_OPTIONS), }, - { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS, secondary: true }, + { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, { key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true }, { // Wins over the `paymentStatus` filter above — the queue is by @@ -149,8 +149,10 @@ export default function BookingRequestsPage() { toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}), }, { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true }, - { key: "originYardId", label: "Origin", type: "enum", multiple: false, options: yardOptions, secondary: true }, - { key: "destinationYardId", label: "Destination", type: "enum", multiple: false, options: yardOptions, secondary: true }, + { + key: "route", label: "Route", type: "route", options: yardOptions, secondary: true, + toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), + }, { key: "created", label: "Created", type: "date", secondary: true, toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }) }, { key: "scheduled", label: "Scheduled", type: "date", secondary: true, toParams: ({ v }) => ({ scheduledFrom: v[0], scheduledTo: v[1] }) }, ], diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index e50a090a2..969c97d0c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -23,6 +23,8 @@ import { } from "lucide-react"; import { useCallback, useMemo, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; @@ -103,6 +105,16 @@ export default function ContractRequestsPage() { const navigate = useNavigate(); const { filterOptions } = useMyTradeAccess(); + // Yard options for the route filter (shared routes reference list, same + // query BookingRequestsPage uses). + const { data: yardRefs } = useQuery( + api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }), + ); + const yardOptions = useMemo( + () => (yardRefs ?? []).map((y) => ({ value: y.id, label: y.label ?? y.code })), + [yardRefs], + ); + // Static shape only (no facet counts) — this is what useFilters needs to // parse the URL and build API params. Counts are attached separately below, // for rendering only, once the summary query (which itself depends on @@ -123,7 +135,6 @@ export default function ContractRequestsPage() { type: "enum", multiple: false, options: filterOptions(TRADE_DIRECTION_OPTIONS), - secondary: true, }, { key: "freightType", @@ -131,7 +142,6 @@ export default function ContractRequestsPage() { type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS, - secondary: true, }, { key: "paymentCurrency", @@ -150,8 +160,16 @@ export default function ContractRequestsPage() { // onChange, so this just routes to the API's existing param names. toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }), }, + { + key: "route", + label: "Route", + type: "route", + options: yardOptions, + secondary: true, + toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), + }, ], - [filterOptions], + [filterOptions, yardOptions], ); const controls = useFilters(filterDefs, { From 4b4ab21ea1a2725b589934261201e34534f028d4 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 07:31:19 +0000 Subject: [PATCH 15/28] fix(filters): auto-apply radios, route popover bug, date operators, polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Radio-based filters (single-select enum, boolean) apply the instant an option is picked and close — no Apply click needed. Checkbox (multi-select) keeps the explicit Apply, since picking several is a multi-step gesture. - Route filter's Select dropdowns portal separately by default, so a click landed as "outside" the outer pill popover and closed it before a pick registered — same class of bug DateBody had. `comboboxProps={{ withinPortal: false }}` fixes it. - Route filter now pinned by default on both pages instead of behind More filters. - Date filter: widened to before/between/after (repository already applies each bound independently) via a new `dateRangeParams` helper — the old positional `{v[0]:from, v[1]:to}` mapping silently put a "before" pick in the from param. Added a calendar icon + sm sizing, and a wider popover so the range presets sidebar has room. - Pill's clear (X) button enlarged; search box border/text opacity strengthened to match the pill trigger restyle. --- .../src/components/filters/FilterBar.tsx | 11 ++++++++- .../src/components/filters/FilterPill.tsx | 10 +++++--- .../components/filters/bodies/BooleanBody.tsx | 14 +++++------ .../components/filters/bodies/DateBody.tsx | 5 ++++ .../components/filters/bodies/EnumBody.tsx | 23 ++++++++++++++----- .../components/filters/bodies/RouteBody.tsx | 9 ++++++++ .../src/components/filters/dates.ts | 20 ++++++++++++++++ .../pages/bookings/BookingRequestsPage.tsx | 16 +++++++++---- .../pages/contracts/ContractRequestsPage.tsx | 11 +++++---- 9 files changed, 93 insertions(+), 26 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx index 6a0aaac96..e42b70252 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx @@ -84,7 +84,16 @@ export function FilterBar({ onChange={(e) => controls.setSearchText(e.currentTarget.value)} size="xs" radius="lg" - styles={{ input: { fontWeight: 400 } }} + // Regular weight (not the Button-driven 600 the rest of the bar + // uses) and a solid, fully-opaque border/text — same "opaque, not + // faint" fix the inactive pill trigger got. + styles={{ + input: { + fontWeight: 400, + borderColor: "var(--mantine-color-gray-6)", + color: "var(--mantine-color-gray-9)", + }, + }} style={{ minWidth: 160, flex: "1 1 160px" }} /> )} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx index 0275f67ad..af97d2fcf 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx @@ -20,6 +20,10 @@ const BODIES: Record> = { route: RouteBody, }; +// Most bodies fit a narrow popover; a date range needs room for the presets +// sidebar next to the calendar, so it gets a wider minimum. +const DROPDOWN_WIDTH: Partial> = { date: 340 }; + export interface FilterPillProps { def: FilterDef; value: FilterValue | undefined; @@ -57,7 +61,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) active ? ( - + ) : ( @@ -77,7 +81,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) {active ? `${def.label} | ${formatFilterValue(def, value!)}` : def.label} - + setOpened(false)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx index fc59fa3bb..cd9f8ed11 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Button, Radio, Stack } from "@mantine/core"; +import { Radio, Stack } from "@mantine/core"; import { DEFAULT_OP } from "../types"; import type { BooleanFilterDef, Operator } from "../types"; @@ -10,23 +10,23 @@ export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps(value?.op ?? DEFAULT_OP.boolean); const [v, setV] = useState(value?.v[0] ?? ""); - const apply = () => { - onChange(v ? { op, v: [v] } : undefined); + // Two mutually-exclusive options — apply the moment one is picked, same as + // EnumBody's single-select radio. No Apply button needed. + const pick = (next: string) => { + setV(next); + onChange({ op, v: [next] }); onClose(); }; return ( - + - ); } diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx index a7c90c8c9..b2032170d 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Button, Stack } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; +import { CalendarDays } from "lucide-react"; import { getDateRangePresets } from "@/components/common/dateRangePresets"; import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates"; @@ -58,6 +59,8 @@ export function DateBody({ def, value, onChange, onClose }: FilterBodyProps} placeholder="Any" value={[from, to]} onChange={([f, t]) => { @@ -71,6 +74,8 @@ export function DateBody({ def, value, onChange, onClose }: FilterBodyProps ) : ( } placeholder="Any" value={from} onChange={setFrom} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx index 459246dff..3bc352dcb 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx @@ -56,11 +56,20 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps { - onChange(selected.length ? { op, v: selected } : undefined); + const apply = (v: string[] = selected) => { + onChange(v.length ? { op, v } : undefined); onClose(); }; + // Single-select is a radio pick, not a build-up-a-set gesture — apply the + // instant one is chosen, same as picking an option in a plain Select. + // Checkbox (multiple) still needs the explicit Apply: picking several + // options is a multi-step gesture the popover shouldn't close mid-way through. + const applyRadio = (v: string) => { + setSelected([v]); + apply([v]); + }; + return ( @@ -95,7 +104,7 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps setSelected(v ? [v] : [])} + onChange={(v) => v && applyRadio(v)} aria-label={`Filter by ${def.label}`} > @@ -120,9 +129,11 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps )} - + {multiple && ( + + )} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx index 9e86199a9..55097b05b 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx @@ -20,6 +20,13 @@ export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps { - setSearch(event.target.value); - setPage(1); - }} - /> - +
@@ -379,10 +391,10 @@ function FleetCrudPage({ Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
- -
@@ -487,6 +499,47 @@ const statusBadge = (status?: string) => {status ?? '-' const optionLabel = (options: { value: string; label: string }[], value?: string | null) => options.find((option) => option.value === value)?.label ?? value ?? '-'; +const TRAIN_STATUS_OPTIONS: FilterOption[] = [ + { value: 'AVAILABLE', label: 'Available' }, + { value: 'SCHEDULED', label: 'Scheduled' }, + { value: 'IN_SERVICE', label: 'In service' }, + { value: 'UNDER_MAINTENANCE', label: 'Under maintenance' }, + { value: 'OUT_OF_SERVICE', label: 'Out of service' }, + { value: 'DEACTIVATED', label: 'Deactivated' }, +]; + +const WAGON_STATUS_OPTIONS: FilterOption[] = [ + { value: 'AVAILABLE', label: 'Available' }, + { value: 'IMPORT_READY', label: 'Import ready' }, + { value: 'EXPORT_READY', label: 'Export ready' }, + { value: 'ASSIGNED', label: 'Assigned' }, + { value: 'MAINTENANCE', label: 'Maintenance' }, + { value: 'DETAINED', label: 'Detained' }, +]; + +const CONTAINER_STATUS_OPTIONS: FilterOption[] = [ + { value: 'AVAILABLE', label: 'Available' }, + { value: 'LOADED', label: 'Loaded' }, + { value: 'IN_TRANSIT', label: 'In transit' }, + { value: 'MAINTENANCE', label: 'Maintenance' }, + { value: 'DAMAGED', label: 'Damaged' }, +]; + +const CARGO_STATUS_OPTIONS: FilterOption[] = [ + { value: 'PENDING', label: 'Pending' }, + { value: 'LOADED', label: 'Loaded' }, + { value: 'IN_TRANSIT', label: 'In transit' }, + { value: 'DELIVERED', label: 'Delivered' }, + { value: 'UNLOADED', label: 'Unloaded' }, +]; + +const LOCOMOTIVE_STATUS_OPTIONS: FilterOption[] = [ + { value: 'AVAILABLE', label: 'Available' }, + { value: 'MAINTENANCE', label: 'Maintenance' }, + { value: 'ASSIGNED', label: 'Assigned' }, + { value: 'OUT_OF_SERVICE', label: 'Out of service' }, +]; + export function TrainMasterDataPage() { const query = useQuery(api.trains.list.queryOptions()); return ( @@ -499,6 +552,7 @@ export function TrainMasterDataPage() { create={useMutation(api.trains.create.mutationOptions())} update={useMutation(api.trains.update.mutationOptions())} remove={useMutation(api.trains.remove.mutationOptions())} + statusOptions={TRAIN_STATUS_OPTIONS} searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')} columns={[ { key: 'code', label: 'Code' }, @@ -522,14 +576,17 @@ export function TrainMasterDataPage() { ); } +const WAGON_TYPE_FILTER_DEFS: FilterDef[] = [ + { key: 'isActive', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' }, +]; + export function WagonTypesCrudPage() { const query = useQuery(api.wagonTypes.list.queryOptions()); const create = useMutation(api.wagonTypes.create.mutationOptions()); const update = useMutation(api.wagonTypes.update.mutationOptions()); const remove = useMutation(api.wagonTypes.remove.mutationOptions()); const { toast } = useToast(); - const [search, setSearch] = useState(''); - const [page, setPage] = useState(1); + const controls = useFilters(WAGON_TYPE_FILTER_DEFS, { pageSize: 10 }); const [sortKey, setSortKey] = useState('code'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [formOpen, setFormOpen] = useState(false); @@ -546,18 +603,15 @@ export function WagonTypesCrudPage() { }); const [fieldErrors, setFieldErrors] = useState>({}); - const pageSize = 10; - const filtered = useMemo(() => { - const queryText = search.trim().toLowerCase(); - const rows = query.data ?? []; - if (!queryText) return rows; - return rows.filter((type) => - [type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive'] - .join(' ') - .toLowerCase() - .includes(queryText), - ); - }, [query.data, search]); + const pageSize = controls.pageSize; + const page = controls.page; + const filtered = useMemo( + () => + applyClientFilters(query.data ?? [], WAGON_TYPE_FILTER_DEFS, controls.values, controls.searchText, { + searchValue: (type) => [type.code, type.name, type.supportedLoadTypes?.join(' ')].join(' '), + }), + [query.data, controls.values, controls.searchText], + ); const sorted = useMemo(() => { return [...filtered].sort((left, right) => { @@ -573,7 +627,7 @@ export function WagonTypesCrudPage() { const isSaving = create.isPending || update.isPending; const toggleSort = (key: keyof WagonType) => { - setPage(1); + controls.setPage(1); if (sortKey === key) { setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')); return; @@ -689,16 +743,7 @@ export function WagonTypesCrudPage() { - } - placeholder="Search wagon types" - value={search} - onChange={(event) => { - setSearch(event.currentTarget.value); - setPage(1); - }} - /> + @@ -789,7 +834,7 @@ export function WagonTypesCrudPage() { Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '} {sorted.length} - + @@ -906,6 +951,7 @@ export function WagonsCrudPage() { create={useMutation(api.wagons.create.mutationOptions())} update={useMutation(api.wagons.update.mutationOptions())} remove={useMutation(api.wagons.remove.mutationOptions())} + statusOptions={WAGON_STATUS_OPTIONS} searchText={(wagon) => [ wagon.wagonNumber, wagon.wagonTypeId, @@ -959,14 +1005,7 @@ export function WagonsCrudPage() { key: 'status', label: 'Status', type: 'select', - options: [ - { value: 'AVAILABLE', label: 'Available' }, - { value: 'IMPORT_READY', label: 'Import ready' }, - { value: 'EXPORT_READY', label: 'Export ready' }, - { value: 'ASSIGNED', label: 'Assigned' }, - { value: 'MAINTENANCE', label: 'Maintenance' }, - { value: 'DETAINED', label: 'Detained' }, - ], + options: WAGON_STATUS_OPTIONS, }, { key: 'notes', label: 'Notes' }, ]} @@ -999,6 +1038,7 @@ export function ContainersCrudPage() { create={useMutation(api.containers.create.mutationOptions())} update={useMutation(api.containers.update.mutationOptions())} remove={useMutation(api.containers.remove.mutationOptions())} + statusOptions={CONTAINER_STATUS_OPTIONS} searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')} columns={[ { key: 'containerNumber', label: 'Number' }, @@ -1058,6 +1098,7 @@ export function CargoesCrudPage() { create={useMutation(api.cargoes.create.mutationOptions())} update={useMutation(api.cargoes.update.mutationOptions())} remove={useMutation(api.cargoes.remove.mutationOptions())} + statusOptions={CARGO_STATUS_OPTIONS} searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')} columns={[ { key: 'cargoReference', label: 'Reference' }, @@ -1122,6 +1163,7 @@ export function LocomotivesCrudPage() { removeActionLabel="Decommission" removeConfirmMessage="Decommission this locomotive?" removeSuccessMessage="Locomotive decommissioned" + statusOptions={LOCOMOTIVE_STATUS_OPTIONS} searchText={(locomotive) => [ locomotive.code, @@ -1166,12 +1208,7 @@ export function LocomotivesCrudPage() { label: 'Status', type: 'select', required: true, - options: [ - { value: 'AVAILABLE', label: 'Available' }, - { value: 'MAINTENANCE', label: 'Maintenance' }, - { value: 'ASSIGNED', label: 'Assigned' }, - { value: 'OUT_OF_SERVICE', label: 'Out of service' }, - ], + options: LOCOMOTIVE_STATUS_OPTIONS, }, { key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true }, { key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true }, From 69805315d96ebe95debb57c2c86f049ffeb7d044 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 08:07:06 +0000 Subject: [PATCH 17/28] feat: dev and staging bypass --- apps/edr-freight-api/.env.example | 6 ++ .../src/common/dev-bypass.util.ts | 13 ++++ .../src/modules/otp/otp.service.ts | 26 ++++++- .../src/modules/payment/payment.service.ts | 75 ++++++++++++------- .../modules/verifayda/verifayda.service.ts | 25 +++++++ .../src/components/FaydaVerifyPanel.tsx | 21 ++++++ .../portal/src/utils/dev-bypass.ts | 4 + apps/edr-freight-web/portal/src/vite-env.d.ts | 2 + 8 files changed, 142 insertions(+), 30 deletions(-) create mode 100644 apps/edr-freight-api/src/common/dev-bypass.util.ts create mode 100644 apps/edr-freight-web/portal/src/utils/dev-bypass.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 5a145a0db..d2359dd97 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,4 +1,10 @@ # Copy to .env for local/docker compose (not committed). + +# Set to "dev" or "staging" to bypass OTP (fixed code 000000 also accepted), +# payment (invoice auto-marked paid on initiate, no gateway call) and Fayda +# (canned verified profile, no eSignet call). Leave unset in production. +ENV= + PORT=3001 # @tria-plc/auditlog's client interceptor stamps every AuditLog row's # `application` from this env var directly, bypassing MezgebModule.forRoot's diff --git a/apps/edr-freight-api/src/common/dev-bypass.util.ts b/apps/edr-freight-api/src/common/dev-bypass.util.ts new file mode 100644 index 000000000..5e6e39e8d --- /dev/null +++ b/apps/edr-freight-api/src/common/dev-bypass.util.ts @@ -0,0 +1,13 @@ +/** + * Dev/staging bypass gate for OTP, payment and Fayda verification. + * + * Gated purely on `ENV` (dev|staging) — never on NODE_ENV, so it can't be + * mistaken for a prod-vs-non-prod switch. `ENV` is simply left unset in + * production, so this is always false there. + */ +export function isBypassEnv(): boolean { + return ["dev", "staging"].includes(process.env.ENV ?? ""); +} + +/** Fixed code accepted in addition to the real one when isBypassEnv(). */ +export const DEV_BYPASS_OTP = "000000"; diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 42d46226a..b27520c25 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -8,6 +8,7 @@ import { OtpRepository } from "./otp.repository"; import { NotificationsService } from "../notifications/notifications.service"; import { EmailClientService } from "../notifications/email-client.service"; +import { isBypassEnv, DEV_BYPASS_OTP } from "../../common/dev-bypass.util"; /** * Where a code goes. At least one of phone/email must be set — enforced by the @@ -146,6 +147,21 @@ export class OtpService { `otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`, ); + // Dev/staging only: the row above still exists (so a real code would + // still verify), but skip the real SMS/email send — no carrier cost, no + // dependency on RabbitMQ/the mail relay being up. Verify with the fixed + // DEV_BYPASS_OTP code instead of whatever landed in the row. + if (isBypassEnv()) { + this.logger.warn( + `otp.dispatch.bypassed target=${label} — dev/staging, no real SMS/email sent (verify with ${DEV_BYPASS_OTP})`, + ); + return { + success: true, + delivered: true, + message: "OTP sent successfully", + }; + } + // NOTE: do NOT reset the brute-force attempt counter on send. Clearing it // here let an attacker wipe the per-target guess budget just by calling // /otp/send between guesses. The counter is cleared only when the code is @@ -394,7 +410,10 @@ export class OtpService { // invalid otp — per-target attempt cap so a 6-digit code can't be // brute-forced within its TTL; the code is burned once the budget is spent. - if (otpData.otp !== otp) { + // Dev/staging only: a fixed code verifies any pending OTP row without + // knowing the real one — the row still has to exist (sendOtp still runs). + const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP; + if (otpData.otp !== otp && !bypassed) { const attempts = (this.actionAttempts.get(key) ?? 0) + 1; if (attempts >= this.MAX_ACTION_ATTEMPTS) { await this.otpRepository.deleteOtp(otpData); @@ -482,7 +501,10 @@ export class OtpService { ); } - if (otpData.otp !== otp) { + // Dev/staging only: a fixed code verifies any pending OTP row without + // knowing the real one — the row still has to exist (sendOtp still runs). + const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP; + if (otpData.otp !== otp && !bypassed) { const attempts = (this.actionAttempts.get(key) ?? 0) + 1; if (attempts >= this.MAX_ACTION_ATTEMPTS) { diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 037e188f8..168fa3df4 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -31,6 +31,7 @@ import { IntentStatusDto, PaymentPlatformDto, } from "./payments.dto"; +import { isBypassEnv } from "../../common/dev-bypass.util"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by * the caller (billing) — this service never derives them from a domain record. */ @@ -256,34 +257,52 @@ export class PaymentService { ); } - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: PaymentReferenceType.SHIPMENT, - referenceId: input.referenceId, - orderRef: input.orderRef, - // CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was - // debited against the intent amount, so the dev shortcut would break it. - // CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev - // shortcut floor is 10, not 1. - // amountMinor: isCbeBill - // ? input.amountMinor - // : input.method === ProviderMethod.CAC_BANK - // ? 10 - // : 1, - amountMinor: input.amountMinor, - currency: input.currency, - provider: input.method as ProviderMethod, - platform: input.platform, - payerAccount: input.payerAccount, - payerName: input.payerName, - expiresAt: input.expiresAt, - // bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING). - returnUrl: - input.returnUrl ?? - `https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`, - failureUrl: - input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", - }); + // Dev/staging only: skip the real gateway call entirely and report an + // immediate SUCCEEDED snapshot — everything below (upsert, settle, + // billing notify) runs exactly as it would for a real synchronous + // provider success. + const snapshot: PaymentIntentSnapshot = isBypassEnv() + ? { + intentId: `bypass-${input.referenceId}`, + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + merchantOrderId: input.orderRef, + provider: input.method as ProviderMethod, + status: ProviderPaymentStatus.SUCCEEDED, + amountMinor: input.amountMinor, + currency: input.currency, + providerTxnId: `bypass-${input.referenceId}`, + paidAt: new Date().toISOString(), + } + : await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + // CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was + // debited against the intent amount, so the dev shortcut would break it. + // CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev + // shortcut floor is 10, not 1. + // amountMinor: isCbeBill + // ? input.amountMinor + // : input.method === ProviderMethod.CAC_BANK + // ? 10 + // : 1, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + payerName: input.payerName, + expiresAt: input.expiresAt, + // bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING). + returnUrl: + input.returnUrl ?? + `https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`, + failureUrl: + input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + }); const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index af627f25a..298cae11e 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -28,6 +28,11 @@ import { NormalizedFaydaUserInfo, VerifaydaPurpose, } from './verifayda.types'; +import { randomUUID } from 'node:crypto'; +import { isBypassEnv } from '../../common/dev-bypass.util'; + +/** Sentinel `code` that skips the real eSignet exchange in dev/staging. */ +export const DEV_BYPASS_FAYDA_CODE = 'DEV_BYPASS'; export interface StartVerificationInput { purpose: VerifaydaPurpose; @@ -143,6 +148,26 @@ export class VerifaydaService { async completeVerification( query: VerifaydaCallbackDto, ): Promise { + // Dev/staging only: caller sends the sentinel code instead of a real + // eSignet redirect — skip the token exchange/session entirely and hand + // back a canned VERIFY result. `sub` is unique per call so binding both + // owner and PoA in the same bypass session doesn't collide. + if (isBypassEnv() && query.code === DEV_BYPASS_FAYDA_CODE) { + this.logger.warn('Fayda verification BYPASSED (dev/staging)'); + return { + purpose: 'VERIFY', + verified: true, + sub: `dev-bypass-${randomUUID()}`, + fullName: 'Dev Bypass User', + email: 'dev-bypass@example.com', + phoneNumber: '+251900000000', + birthdate: '1990-01-01', + gender: 'M', + address: 'Dev Bypass Address', + userDataSaved: false, + }; + } + if (query.error) { this.logger.warn(`Fayda callback returned error: ${query.error}`); if (query.state) { diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index 4e6921e14..71cef57da 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -25,6 +25,10 @@ import { type IdentitySubject, type IdentityVerificationState, } from "@/services/verifayda.service"; +import { isBypassEnv } from "@/utils/dev-bypass"; + +/** Sentinel code that skips the real eSignet exchange in dev/staging (see api's DEV_BYPASS_FAYDA_CODE). */ +const DEV_BYPASS_FAYDA_CODE = "DEV_BYPASS"; interface FaydaVerifyPanelProps { subject: IdentitySubject; @@ -80,6 +84,23 @@ export default function FaydaVerifyPanel({ setError(null); setLoading(true); try { + // Dev/staging only: send the tab straight to /fayda/callback with the + // sentinel code instead of round-tripping through eSignet — that page's + // existing completeIdentity()/navigate-back logic runs unchanged. + if (isBypassEnv()) { + stashPendingVerification({ + subject, + returnTo: + window.location.pathname + + window.location.search + + window.location.hash, + }); + window.location.assign( + `/fayda/callback?code=${DEV_BYPASS_FAYDA_CODE}&state=bypass`, + ); + return; + } + const authorizationUrl = await verifaydaService.start(); // Record who is being verified and where to come back to before the tab // leaves — /fayda/callback has no other way to know either. diff --git a/apps/edr-freight-web/portal/src/utils/dev-bypass.ts b/apps/edr-freight-web/portal/src/utils/dev-bypass.ts new file mode 100644 index 000000000..69eb23242 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/dev-bypass.ts @@ -0,0 +1,4 @@ +/** True when VITE_ENV is "dev" or "staging" — mirrors the API's ENV gate. */ +export function isBypassEnv(): boolean { + return ["dev", "staging"].includes(import.meta.env.VITE_ENV ?? ""); +} diff --git a/apps/edr-freight-web/portal/src/vite-env.d.ts b/apps/edr-freight-web/portal/src/vite-env.d.ts index a4955ecee..6a93b9e75 100644 --- a/apps/edr-freight-web/portal/src/vite-env.d.ts +++ b/apps/edr-freight-web/portal/src/vite-env.d.ts @@ -23,6 +23,8 @@ interface ImportMetaEnv { readonly VITE_POSTHOG_KEY?: string; /** Self-hosted PostHog instance URL. */ readonly VITE_POSTHOG_HOST?: string; + /** "dev" | "staging" — enables the OTP/payment/Fayda bypass. Unset in prod. */ + readonly VITE_ENV?: string; } interface ImportMeta { From 83d9265e85bb828e747705183097b8fde1e5eb60 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 14 Aug 2026 13:51:00 +0000 Subject: [PATCH 18/28] Credit and Debt plus reason attribute inide register --- .../3550000000000-EimsDebitCreditNotes.ts | 28 ++++++++++ .../billing/eims-invoice.mapper.spec.ts | 53 +++++++++++++++++++ .../modules/billing/eims-invoice.mapper.ts | 51 ++++++++++++++++-- .../billing/entities/invoice.entity.ts | 23 ++++++++ .../src/modules/eims/eims-invoice-context.ts | 9 ++++ .../eims-invoice-registration.service.spec.ts | 52 ++++++++++++++++++ .../eims/eims-invoice-registration.service.ts | 27 +++++++++- 7 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts diff --git a/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts b/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts new file mode 100644 index 000000000..cf36df757 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Debit/credit note filing — confirmed directly by MoR support: same `/v1/register` endpoint, + * distinguished by `DocumentDetails.Type` ("DEB"/"CRE") + a `Reason`, linked to the original + * invoice via `ReferenceDetails.RelatedDocument`. See `Invoice.eimsDocumentType`. + */ +export class EimsDebitCreditNotes3550000000000 implements MigrationInterface { + name = "EimsDebitCreditNotes3550000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_document_type varchar(8) NOT NULL DEFAULT 'INV', + ADD COLUMN IF NOT EXISTS eims_reason text, + ADD COLUMN IF NOT EXISTS related_invoice_id uuid REFERENCES freight.invoices(id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_document_type, + DROP COLUMN IF EXISTS eims_reason, + DROP COLUMN IF EXISTS related_invoice_id + `); + } +} 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 927e2efec..7406613fc 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 @@ -212,6 +212,59 @@ describe("toEimsInvoice", () => { expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/); }); + describe("debit/credit notes — confirmed by MoR support, same /v1/register endpoint", () => { + it("defaults DocumentDetails.Type to INV with no Reason field", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + expect(doc.DocumentDetails.Type).toBe("INV"); + expect(doc.DocumentDetails).not.toHaveProperty("Reason"); + }); + + it("files a credit note with Type, Reason and RelatedDocument", () => { + const doc = toEimsInvoice( + invoice(), + seller, + context({ + documentType: "CRE", + reason: "Overbilled freight charge", + relatedDocument: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + }), + ); + expect(doc.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" }); + expect(doc.ReferenceDetails.RelatedDocument).toBe( + "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + ); + }); + + it("files a debit note the same way", () => { + const doc = toEimsInvoice( + invoice(), + seller, + context({ documentType: "DEB", reason: "Additional handling fee", relatedDocument: "IRN-1" }), + ); + expect(doc.DocumentDetails).toMatchObject({ Type: "DEB", Reason: "Additional handling fee" }); + }); + + it("throws when a credit/debit note has no reason", () => { + expect(() => + toEimsInvoice( + invoice(), + seller, + context({ documentType: "CRE", reason: null, relatedDocument: "IRN-1" }), + ), + ).toThrow(/needs a reason/); + }); + + it("throws when a credit/debit note has no relatedDocument", () => { + expect(() => + toEimsInvoice( + invoice(), + seller, + context({ documentType: "CRE", reason: "Overbilled", relatedDocument: null }), + ), + ).toThrow(/needs.*relatedDocument/); + }); + }); + it("throws when the lines do not sum to the invoice total", () => { expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow( /lines sum to 11000 but the invoice total is 9000/, 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 fb7f2f145..582e3d326 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 @@ -20,8 +20,15 @@ import { round2 } from "./invoice-settlement.util"; /** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ const EIMS_VERSION = "1"; -/** The only `DocumentDetails.Type` observed in the supplied material. */ -const EIMS_DOCUMENT_TYPE = "INV"; +/** + * `DocumentDetails.Type`. `"INV"` is the only value observed in the collection; `"DEB"`/`"CRE"` + * (debit/credit note) were confirmed directly by MoR support — same `/v1/register` endpoint, no + * separate API. MoR's answer, verbatim: "the same endpoint used for registration should be used + * ... within the Document Detail object, you should specify DEB for a debit note, CRE for a + * credit note... add a Reason attribute under document detail object". + */ +export const EIMS_DOCUMENT_TYPES = ["INV", "DEB", "CRE"] as const; +export type EimsDocumentType = (typeof EIMS_DOCUMENT_TYPES)[number]; export interface EimsBuyerDetails { City: string | null; @@ -60,7 +67,9 @@ export interface EimsDocumentDetails { DocumentNumber: string; /** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */ Date: string; - Type: string; + Type: EimsDocumentType; + /** Only for DEB/CRE, per MoR support — why the debit/credit note was issued. Absent for INV. */ + Reason?: string; } export interface EimsInvoiceItem { @@ -212,7 +221,18 @@ export interface EimsMapperContext { unitDefault: string; incomeWithholdValue: number; transactionWithholdValue: number; - /** Null for an ordinary invoice; set only for a real related-document case. */ + /** + * `DocumentDetails.Type`. Defaults to `"INV"`. For `"DEB"`/`"CRE"` both `reason` and + * `relatedDocument` become required — confirmed directly by MoR support, not the collection. + */ + documentType?: EimsDocumentType; + /** Required when `documentType` is `"DEB"`/`"CRE"` — why the note was issued. Unused for INV. */ + reason?: string | null; + /** + * `ReferenceDetails.RelatedDocument`. Null for an ordinary invoice; required for a DEB/CRE — + * the original registered invoice's IRN, per MoR's own IRC-P06/P07 checklist ("credit memo + * from a registered invoice"). + */ relatedDocument?: string | null; /** MoR numeric country code for the buyer; our DB stores the country name. */ buyerCountryCode?: string | null; @@ -326,6 +346,26 @@ export function toEimsInvoice( ); } + const documentType = context.documentType ?? "INV"; + if (!EIMS_DOCUMENT_TYPES.includes(documentType)) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} has documentType "${documentType}", must be one of ${EIMS_DOCUMENT_TYPES.join(", ")}`, + ); + } + if (documentType !== "INV") { + if (!context.reason?.trim()) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs a reason`, + ); + } + if (!context.relatedDocument?.trim()) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs ` + + "relatedDocument — the original registered invoice's IRN", + ); + } + } + const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt); if (Number.isNaN(issuedAt.getTime())) { throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); @@ -430,7 +470,8 @@ export function toEimsInvoice( DocumentDetails: { DocumentNumber: context.documentNumber, Date: (context.formatDate ?? formatEimsDate)(issuedAt), - Type: EIMS_DOCUMENT_TYPE, + Type: documentType, + ...(documentType !== "INV" ? { Reason: context.reason! } : {}), }, ItemList, PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term }, diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 7d0ff34c4..43b6e3271 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -180,4 +180,27 @@ export class Invoice extends BaseEntity { @Column({ name: "eims_cancellation_remark", type: "text", nullable: true }) eimsCancellationRemark?: string | null; + + /** + * `DocumentDetails.Type` to file this invoice as — "INV" (default), "DEB" or "CRE". Confirmed + * by MoR support directly (not the collection): debit/credit notes go through this same + * `/v1/register` endpoint, distinguished only by `Type` + `Reason`, linked via + * `ReferenceDetails.RelatedDocument` to the original invoice's IRN. This module does not create + * debit/credit note invoices — that is a freight-workflow decision — it only files one + * correctly once these columns are set on an existing row. + */ + @Column({ name: "eims_document_type", type: "varchar", length: 8, default: "INV" }) + eimsDocumentType!: string; + + /** Required by MoR when `eimsDocumentType` is DEB/CRE — why the note was issued. */ + @Column({ name: "eims_reason", type: "text", nullable: true }) + eimsReason?: string | null; + + /** The original registered invoice this debit/credit note adjusts. Required for DEB/CRE. */ + @Column({ name: "related_invoice_id", type: "uuid", nullable: true }) + relatedInvoiceId?: string | null; + + @ManyToOne(() => Invoice) + @JoinColumn({ name: "related_invoice_id" }) + relatedInvoice?: Invoice | null; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 8e7be0197..b77804eda 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -157,6 +157,12 @@ export interface EimsContextInput { session: EimsSessionContext; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; + /** `DocumentDetails.Type` — defaults to "INV" in the mapper when omitted. */ + documentType?: EimsMapperContext["documentType"]; + /** Required (by the mapper) when documentType is DEB/CRE. */ + reason?: string | null; + /** `ReferenceDetails.RelatedDocument` — the original invoice's IRN, required for DEB/CRE. */ + relatedDocument?: string | null; } export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext { @@ -206,5 +212,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, exchangeRate: input.exchangeRate ?? null, + documentType: input.documentType, + reason: input.reason ?? null, + relatedDocument: input.relatedDocument ?? null, }; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 8c2a33cfc..36c355bed 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -295,6 +295,58 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); + it("files a credit note with Type/Reason/RelatedDocument from the invoice row", async () => { + const original = invoiceRow({ + id: "original-invoice", + invoiceNumber: "INV-20260807-00001", + eimsIrn: IRN, + }); + const db = new FakeDb([ + invoiceRow({ + eimsDocumentType: "CRE", + eimsReason: "Overbilled freight charge", + relatedInvoice: original, + } as Partial), + ]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + + await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" }); + expect(request.ReferenceDetails.RelatedDocument).toBe(IRN); + }); + + it("refuses a credit/debit note whose related invoice was never registered, before touching a counter", async () => { + const original = invoiceRow({ id: "original-invoice", eimsIrn: null }); + const db = new FakeDb([ + invoiceRow({ + eimsDocumentType: "DEB", + eimsReason: "Additional handling", + relatedInvoice: original, + } as Partial), + ]); + const postSigned = jest.fn(); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7 }); // unchanged — never reserved + }); + + it("refuses a credit/debit note with no related invoice set at all", async () => { + const db = new FakeDb([ + invoiceRow({ eimsDocumentType: "CRE", eimsReason: "x", relatedInvoice: null } as Partial), + ]); + const postSigned = jest.fn(); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(postSigned).not.toHaveBeenCalled(); + }); + it("takes SourceSystem from the token session, not from configuration", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue(okResponse()); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 317f4ec65..ab9f953c5 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -13,6 +13,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { + EimsDocumentType, EimsInvoiceRequest, EimsMapperLine, toEimsInvoice, @@ -96,6 +97,27 @@ export class EimsInvoiceRegistrationService { const invoice = await this.loadInvoiceForMapping(invoiceId); if (invoice.eimsIrn) return this.toView(invoice); + // Debit/credit notes (confirmed by MoR support: same endpoint, Type DEB/CRE + Reason, + // ReferenceDetails.RelatedDocument = the original's IRN) must fail here — before a counter is + // touched — if the original was never actually registered. + const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV"; + let relatedDocument: string | null = null; + if (documentType !== "INV") { + if (!invoice.relatedInvoice) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_REQUIRED", + message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`, + }); + } + if (!invoice.relatedInvoice.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_NOT_REGISTERED", + message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`, + }); + } + relatedDocument = invoice.relatedInvoice.eimsIrn; + } + // Authenticate before reserving: the source system comes from the token, and the state row is // keyed by it. A login failure here costs nothing — no counter has been consumed yet. const session = await this.auth.getSessionContext(); @@ -114,6 +136,9 @@ export class EimsInvoiceRegistrationService { invoiceCounter: reservation.invoiceCounter, previousIrn: reservation.previousIrn, session, + documentType, + reason: invoice.eimsReason, + relatedDocument, }), ); @@ -639,7 +664,7 @@ export class EimsInvoiceRegistrationService { ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { id: invoiceId }, - relations: { company: true, companyProfile: true }, + relations: { company: true, companyProfile: true, relatedInvoice: true }, }); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); From 87c51d76763f108ab4ffc2c086ae51594ae4f39b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 15 Aug 2026 06:44:34 +0000 Subject: [PATCH 19/28] fix(eims): drain pre-reservation rejections in auto-submit sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A BadRequestException thrown before reserve() (config assertion, DEB/CRE validation) left the invoice NOT_SUBMITTED with nothing persisted, so the same row was retried every tick forever — a permanent head-of-line block on every invoice behind it. Now marked FAILED, guarded by a fresh status re-read so a reservation's own SUBMITTING/UNKNOWN/blocked state is never clobbered. Co-Authored-By: Claude Sonnet 5 --- .../eims/eims-auto-submit.service.spec.ts | 48 ++++++++++++++- .../modules/eims/eims-auto-submit.service.ts | 61 ++++++++++++++++--- 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts index 6246d5a89..9b3bb5183 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DataSource } from "typeorm"; @@ -12,6 +13,9 @@ const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; /** * `query` is answered by shape: the first call is the system-state guard, the second is the * candidate lookup. Keeps the fake honest about the order the service actually asks in. + * + * `managerRow` backs `dataSource.manager.findOne`/`.update` — only exercised by the + * pre-reservation-rejection path (`failStalledCandidate`), so it defaults to the candidate itself. */ const build = ( opts: { @@ -19,6 +23,7 @@ const build = ( state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null }; candidate?: { id: string; invoiceNumber: string } | null; register?: jest.Mock; + managerRow?: { eimsStatus: EimsInvoiceStatus } | null; } = {}, ) => { const register = @@ -34,12 +39,17 @@ const build = ( return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []); }); + const managerUpdate = jest.fn().mockResolvedValue(undefined); + const managerFindOne = jest + .fn() + .mockResolvedValue(opts.managerRow === undefined ? { eimsStatus: EimsInvoiceStatus.NotSubmitted } : opts.managerRow); + const service = new EimsAutoSubmitService( - { query } as unknown as DataSource, + { query, manager: { findOne: managerFindOne, update: managerUpdate } } as unknown as DataSource, { get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService, { registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService, ); - return { service, register, query }; + return { service, register, query, managerUpdate, managerFindOne }; }; const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" }; @@ -121,6 +131,40 @@ describe("EimsAutoSubmitService.tick", () => { expect(register).toHaveBeenCalledTimes(1); }); + it("drains a pre-reservation rejection so the sweep advances, without touching the DB row's own reservation state", async () => { + const register = jest + .fn() + .mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" })); + const { service, managerFindOne, managerUpdate } = build({ candidate, register }); + + await expect(service.tick()).resolves.toBeUndefined(); + + expect(managerFindOne).toHaveBeenCalledTimes(1); + expect(managerUpdate).toHaveBeenCalledWith( + expect.anything(), + INVOICE_ID, + expect.objectContaining({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: expect.objectContaining({ message: "no related invoice" }), + }), + ); + }); + + it("leaves a row alone if it already moved past NOT_SUBMITTED by the time the rejection is handled", async () => { + const register = jest + .fn() + .mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" })); + const { service, managerUpdate } = build({ + candidate, + register, + managerRow: { eimsStatus: EimsInvoiceStatus.Submitting }, + }); + + await expect(service.tick()).resolves.toBeUndefined(); + + expect(managerUpdate).not.toHaveBeenCalled(); + }); + it("does not start a second tick while one is still filing", async () => { let release: () => void = () => {}; const register = jest.fn().mockImplementation( diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts index fb5a0d1f6..bad283817 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts @@ -1,12 +1,14 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Cron } from "@nestjs/schedule"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; +import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js"; import { EimsConfig } from "../../config/eims.config"; +import { Invoice } from "../billing/entities/invoice.entity"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; -import { EimsInvoiceStatus } from "./eims-registration.types"; +import { EimsInvoiceError, EimsInvoiceStatus } from "./eims-registration.types"; /** * Files issued invoices with MoR EIMS on a timer. @@ -67,21 +69,62 @@ export class EimsAutoSubmitService { const candidate = await this.nextCandidate(); if (!candidate) return; - const view = await this.registration.registerInvoiceWithEims(candidate.id); - this.logger.log( - `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + - (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), - ); + try { + const view = await this.registration.registerInvoiceWithEims(candidate.id); + this.logger.log( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + + (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), + ); + } catch (err) { + // Every other failure path inside registerInvoiceWithEims persists FAILED/UNKNOWN itself + // (settleFailure) before throwing. A BadRequestException is the one exception: it is only + // ever thrown *before* a reservation is taken (config assertion, DEB/CRE validation), so + // nothing is persisted — left alone, this candidate is picked again next tick forever, a + // permanent head-of-line block on every invoice behind it. Drain it instead. + if (err instanceof BadRequestException) { + await this.failStalledCandidate(candidate, err); + } else { + throw err; + } + } } catch (err) { // Never let a filing failure kill the job. The outcome is already persisted on the invoice - // (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the - // next tick at the guard above. + // (FAILED or UNKNOWN with the gateway's own message, or drained by failStalledCandidate + // above), and a blocked system number stops the next tick at the guard above. this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`); } finally { this.running = false; } } + /** + * Mark a pre-reservation rejection as FAILED so the sweep advances past it — but only if the + * invoice is still exactly where this tick left it. A reservation's own transactions + * (SUBMITTING/UNKNOWN, or a system-wide block) are authoritative; this must never clobber them, + * so the status is re-read fresh rather than trusted from the stale `candidate` row. + */ + private async failStalledCandidate( + candidate: { id: string; invoiceNumber: string }, + err: BadRequestException, + ): Promise { + const current = await this.dataSource.manager.findOne(Invoice, { where: { id: candidate.id } }); + if (current?.eimsStatus !== EimsInvoiceStatus.NotSubmitted) { + this.logger.warn( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation, but is ` + + `no longer NOT_SUBMITTED (${current?.eimsStatus ?? "not found"}) — leaving state untouched.`, + ); + return; + } + const lastError: EimsInvoiceError = { kind: "VALIDATION", message: err.message, at: new Date().toISOString() }; + await this.dataSource.manager.update(Invoice, candidate.id, { + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: lastError, + } as QueryDeepPartialEntity); + this.logger.error( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation: ${err.message}`, + ); + } + /** Why filing is currently impossible for this system number, or null when it is free. */ private async systemBlockReason(): Promise { const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] = From d66cfe23286f4cc83054bd94f4cc7d6cbbeb5ed4 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 15 Aug 2026 06:44:41 +0000 Subject: [PATCH 20/28] feat(billing): issue credit/debit memos against registered invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST billing/invoices/:id/memo files a MoR DEB/CRE memo by reusing createInvoice unchanged. sourceId is the original invoice's own id, not its source's — this structurally keeps memos out of findPayable/expirePayable/ billQuery's sourceId-keyed lookups regardless of status. Credit notes are created settled; debit notes are created open/unpaid as a genuine new receivable, not force-settled. memoIssue is granted to the chief position, not the general finance role. Co-Authored-By: Claude Sonnet 5 --- .../src/modules/billing/billing.controller.ts | 12 ++ .../modules/billing/billing.service.spec.ts | 153 ++++++++++++++++++ .../src/modules/billing/billing.service.ts | 152 +++++++++++++++-- .../src/modules/billing/dto/issue-memo.dto.ts | 71 ++++++++ .../src/seed/freight-permissions.registry.ts | 25 ++- 5 files changed, 396 insertions(+), 17 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5bf1448fd..e65f301b2 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -29,6 +29,7 @@ import { actorLabel } from "../warehouses/current-actor.util"; import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; +import { IssueMemoDto } from "./dto/issue-memo.dto"; @ApiTags("billing") @Controller("billing") @@ -38,6 +39,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.invoices.confirmOffline, + FREIGHT_PERMS.invoices.memoIssue, ]) @ApiBearerAuth() export class BillingController { @@ -99,6 +101,16 @@ export class BillingController { }); } + @Post("invoices/:id/memo") + @BookingStaff(FREIGHT_PERMS.invoices.memoIssue) + @ApiOperation({ + summary: + "Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.", + }) + issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) { + return this.billingService.issueMemo(id, dto); + } + @Get("invoices/:id/document") @BookingStaff(FREIGHT_PERMS.invoices.export) @ApiOperation({ summary: "Download the sealed invoice PDF" }) 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 d9eef5a43..7148fa987 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 @@ -119,6 +119,159 @@ describe("BillingService.generateInvoice", () => { }); }); +describe("BillingService.issueMemo", () => { + const ORIGINAL_ID = "original-invoice-1"; + + function originalInvoice(overrides: Record = {}) { + return { + id: ORIGINAL_ID, + invoiceNumber: "INV-20260807-00042", + eimsIrn: "irn-value", + eimsDocumentType: "INV", + eimsStatus: "REGISTERED", + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + companyId: "company-1", + companyProfileId: "profile-1", + shippingLineCompanyId: null, + currency: "ETB", + totalAmount: 1500, + lines: [ + { chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null }, + { chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null }, + ], + ...overrides, + }; + } + + function build(original: ReturnType) { + const savedLines: unknown[] = []; + const manager = makeManager(savedLines); + const dataSource = { + transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + manager, + }; + const invoices = { findById: jest.fn().mockResolvedValue(original) }; + const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) }; + const service = new BillingService( + dataSource as never, + invoices as never, + invoiceLines as never, + makeEvents() as never, + {} as never, + {} as never, + {} as never, + {} as never, + { get: () => undefined } as never, + ); + return { service, manager, savedLines }; + } + + it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => { + const { service, savedLines } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" }); + + expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/); + expect(memo.totalAmount).toBe(1500); + expect(memo.status).toBe(Freight.InvoiceStatus.Paid); + expect((memo as unknown as Record).eimsDocumentType).toBe("CRE"); + expect((memo as unknown as Record).eimsReason).toBe("Overbilled freight charge"); + expect((memo as unknown as Record).relatedInvoiceId).toBe(ORIGINAL_ID); + expect((memo as unknown as Record).paidAmount).toBe(1500); + expect((memo as unknown as Record).balanceAmount).toBe(0); + expect(savedLines).toHaveLength(2); + }); + + it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => { + const { service } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" }); + + expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/); + expect(memo.status).toBe(Freight.InvoiceStatus.Pending); + expect(memo.balanceAmount).toBe(1500); + expect(memo.paidAmount).toBe(0); + }); + + it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => { + const { service } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" }); + + expect(memo.sourceId).toBe(ORIGINAL_ID); + expect(memo.sourceId).not.toBe("booking-1"); + }); + + it("allows a partial memo with explicit lines instead of copying the original", async () => { + const { service } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { + type: "CRE", + reason: "Partial credit", + lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }], + }); + + expect(memo.totalAmount).toBe(200); + }); + + it("refuses a memo against an invoice never registered with EIMS", async () => { + const { service } = build(originalInvoice({ eimsIrn: null })); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({ + response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }), + }); + }); + + it("refuses a memo against a memo", async () => { + const { service } = build(originalInvoice({ eimsDocumentType: "CRE" })); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow( + "cannot issue a memo against a memo", + ); + }); + + it("refuses a memo against an EIMS-cancelled invoice", async () => { + const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" })); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow( + "cancelled with EIMS", + ); + }); + + it("refuses a credit memo whose total exceeds the original", async () => { + const { service } = build(originalInvoice({ totalAmount: 1500 })); + + await expect( + service.issueMemo(ORIGINAL_ID, { + type: "CRE", + reason: "too much", + lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }], + }), + ).rejects.toThrow(/exceeds/); + }); + + it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => { + const { service } = build(originalInvoice({ totalAmount: 1500 })); + + const memo = await service.issueMemo(ORIGINAL_ID, { + type: "DEB", + reason: "additional charge", + lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }], + }); + + expect(memo.totalAmount).toBe(5000); + }); + + it("refuses a blank reason", async () => { + const { service } = build(originalInvoice()); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow( + "requires a reason", + ); + }); +}); + describe("BillingService.markInvoiceAsPaid", () => { it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { const open = { 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 5afa30128..a0f25f88b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -18,6 +18,7 @@ import { Booking } from "../bookings/entities/booking.entity"; import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; +import { EimsInvoiceStatus } from "../eims/eims-registration.types"; import { FilesService } from "../files/files.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; @@ -25,6 +26,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, + pngDataUrl, } from "./documents/invoice-document.service"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; @@ -145,6 +147,18 @@ export interface GenerateInvoiceInput { status?: Freight.InvoiceStatus; } +/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */ +export type MemoType = "CRE" | "DEB"; + +/** Everything needed to issue a credit or debit memo against an already-registered invoice. */ +export interface IssueMemoInput { + type: MemoType; + /** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */ + reason: string; + /** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */ + lines?: InvoiceLineInput[]; +} + /** Payload broadcast on `${source}.invoice.`. */ export interface InvoiceEventPayload { invoiceId: string; @@ -418,15 +432,6 @@ export class BillingService { ); } - /** - * `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the - * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), - * not a payload we encode ourselves. Wrapped in a data URL, nothing more. - */ - private renderEimsQr(signedQr: string): string { - return `data:image/png;base64,${signedQr}`; - } - /** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */ private async bookingSummaryRows( invoice: Invoice, @@ -542,7 +547,7 @@ export class BillingService { currency: l.currency, })), totals, - qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null, + qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null, }; } @@ -709,11 +714,16 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── - /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ - private nextInvoiceNumber(mg: EntityManager): Promise { + /** + * `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code` + * defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent + * daily sequence (different prefix hashes to a different advisory lock, see + * `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers. + */ + private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise { return nextDailyInvoiceNumber(mg, { table: "freight.invoices", - code: "INV", + code, }); } @@ -736,9 +746,123 @@ export class BillingService { return manager ? run(manager) : this.dataSource.transaction(run); } + /** + * Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed + * DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`, + * `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice` + * unchanged: it has no side effects (no events, no notifications, no payment records — every + * event in this service fires from `runTransition` on a *transition*, not on create), so a memo + * is just an ordinary invoice with three extra columns set. + * + * `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId` + * (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by + * `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the + * newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own + * `id` is never a value those lookups are ever queried with, so this isolates a memo from all + * of them regardless of its status — no `type`-based exclusion needed anywhere else. + * + * A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so + * leaving it payable would only add a phantom receivable that no payment flow will ever close. + * A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary + * invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is + * findable and collectible through the normal invoice list/detail/payment tooling, safe from + * the CBE/booking-linked lookups above for the `sourceId` reason just given. + */ + async issueMemo( + originalId: string, + input: IssueMemoInput, + ): Promise { + const reason = input.reason?.trim(); + if (!reason) { + throw new BadRequestException("A memo requires a reason."); + } + + const original = await this.findById(originalId); + if (!original.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_NOT_REGISTERED", + message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`, + }); + } + if (original.eimsDocumentType && original.eimsDocumentType !== "INV") { + throw new BadRequestException( + `Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`, + ); + } + if (original.eimsStatus === EimsInvoiceStatus.Cancelled) { + throw new BadRequestException( + `Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`, + ); + } + + const sourceLines = input.lines?.length ? input.lines : original.lines; + const lines: InvoiceLineInput[] = sourceLines.map((l) => ({ + chargeType: l.chargeType, + description: l.description, + quantity: Number(l.quantity), + unitRate: Number(l.unitRate), + amount: Number(l.amount), + currency: l.currency, + metadata: l.metadata ?? null, + })); + + const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0)); + if (!(total > 0)) { + throw new BadRequestException("A memo must have a positive total."); + } + // Only a credit note is bounded by the original — it can only give back what was charged. A + // debit note is an additional charge, not a refund, so no such ceiling applies to it (do not + // assume the credit-note ceiling is correct for DEB). + if (input.type === "CRE" && total > Number(original.totalAmount)) { + throw new BadRequestException( + `Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`, + ); + } + + const code = input.type === "CRE" ? "CRE" : "DEB"; + const settled = input.type === "CRE"; + + return this.dataSource.transaction(async (mg) => { + const memo = await this.createInvoice( + { + source: original.source as Freight.InvoiceSource, + sourceId: original.id, + type: input.type === "CRE" ? "credit_note" : "debit_note", + companyId: original.companyId, + companyProfileId: original.companyProfileId, + shippingLineCompanyId: original.shippingLineCompanyId, + lines, + currency: original.currency, + subtotalAmount: total, + taxAmount: 0, + totalAmount: total, + ...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}), + }, + mg, + code, + ); + + const patch: Record = { + eimsDocumentType: input.type, + eimsReason: reason, + relatedInvoiceId: original.id, + ...(settled + ? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() } + : {}), + }; + await mg.update(Invoice, memo.id, patch); + + this.logger.log( + `Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`, + ); + return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] }; + }); + } + private async createInvoice( input: GenerateInvoiceInput, mg: EntityManager, + code = "INV", ): Promise { const currency = input.currency ?? "ETB"; const status = input.status ?? Freight.InvoiceStatus.Pending; @@ -786,7 +910,7 @@ export class BillingService { (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); - const invoiceNumber = await this.nextInvoiceNumber(mg); + const invoiceNumber = await this.nextInvoiceNumber(mg, code); const invoice = await mg.save( mg.create(Invoice, { diff --git a/apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts new file mode 100644 index 000000000..c80657155 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts @@ -0,0 +1,71 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsIn, + IsNumber, + IsObject, + IsOptional, + IsString, + Length, + ValidateNested, +} from "class-validator"; + +/** One line on a memo; omit the whole `lines` array on the parent DTO to copy the original's. */ +export class MemoLineDto { + @ApiProperty() + @IsString() + chargeType!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + quantity?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + unitRate?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + amount?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + metadata?: Record; +} + +/** `POST billing/invoices/:id/memo` body — see `BillingService.issueMemo`. */ +export class IssueMemoDto { + @ApiProperty({ enum: ["CRE", "DEB"], description: "MoR DocumentDetails.Type for the memo." }) + @IsIn(["CRE", "DEB"]) + type!: "CRE" | "DEB"; + + @ApiProperty({ description: "Why the memo was issued — MoR DocumentDetails.Reason." }) + @IsString() + @Length(1, 500) + reason!: string; + + @ApiPropertyOptional({ + type: [MemoLineDto], + description: "Omit to copy every line of the original invoice verbatim.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MemoLineDto) + lines?: MemoLineDto[]; +} 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 adbd22584..9e905df6d 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -569,6 +569,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:eims_receipt_register", "Register a sales or withholding receipt with MoR EIMS", ), + // Issuing a credit/debit memo is itself filing-equivalent — auto-submit picks it up like any + // other issued invoice — so it carries the same restricted grant as the eims_* actions above, + // not invoices:export. + perm( + "d2b00001-0001-4000-8000-00000000000a", + "edr_freight_app:invoices:memo_issue", + "Issue a credit or debit memo against a registered invoice", + ), // USD bookings are paid by bank transfer; Finance uploads the slip and settles // the invoice. Moves money state, so it is its own grant, not part of view. perm( @@ -1874,6 +1882,7 @@ export const FREIGHT_PERMS = { eimsResolve: "edr_freight_app:invoices:eims_resolve", eimsCancel: "edr_freight_app:invoices:eims_cancel", eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register", + memoIssue: "edr_freight_app:invoices:memo_issue", confirmOffline: "edr_freight_app:invoices:confirm_offline", }, firstMile: { @@ -2406,9 +2415,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, - // eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so - // filing is not a Finance job function — the endpoints exist for controlled testing and - // exceptional operations, and are assigned to named admins rather than a role preset. + // 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 + // endpoints, and stay off the general Finance role. They are granted to the `chief` position + // instead — see below — the same maker–checker split already used for shipping-line credit + // mark-paid/cancel (Finance raises, chief decides). FREIGHT_PERMS.payments.view, FREIGHT_PERMS.bookings.wagonCancellationView, // Shipping-line credit ledger is a Finance surface: bill batches into @@ -2519,6 +2530,14 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.governmentExpedite, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + // Manual MoR EIMS actions and credit/debit memo issuance: kept off the general Finance role + // (see that preset's comment) and granted here instead — the chief is already the decision + // side of every other sensitive finance action (mark-paid/cancel approval below), and these + // are irreversible-at-MoR or receivable-creating in the same way. + FREIGHT_PERMS.invoices.eimsCancel, + FREIGHT_PERMS.invoices.eimsResolve, + FREIGHT_PERMS.invoices.eimsReceiptRegister, + FREIGHT_PERMS.invoices.memoIssue, FREIGHT_PERMS.payments.view, // Decision side of the credit-invoice two-step: finance raises // mark-paid/cancel requests, the chief approves or rejects them. From 8e70352407ff65193652f876890cd7d7a2766490 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 15 Aug 2026 06:44:53 +0000 Subject: [PATCH 21/28] feat(eims): printable receipt PDF with RRN and QR eims-receipt-document.mapper.ts maps an EimsReceipt onto the shared InvoiceDocumentModel layout, reading amounts back out of the stored request body. Refuses to render anything not REGISTERED. GET invoices/:id/eims/receipts/:receiptId/document, scoped to the invoice. Co-Authored-By: Claude Sonnet 5 --- .../documents/invoice-document.service.ts | 9 ++ .../modules/eims/eims-invoice.controller.ts | 16 ++- .../eims/eims-receipt-document.mapper.ts | 107 ++++++++++++++++++ .../modules/eims/eims-receipt.service.spec.ts | 80 ++++++++++++- .../src/modules/eims/eims-receipt.service.ts | 28 +++++ .../src/modules/eims/eims.module.ts | 4 + 6 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index f6b264636..2d5e97e92 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -18,6 +18,15 @@ import { export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; +/** + * MoR returns `signedQR`/`qr` as a base64 PNG already rendered server-side — confirmed against the + * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), not a + * payload we encode ourselves. Wrap, don't encode. Shared by `Invoice.eimsSignedQr` + * (`BillingService`) and `EimsReceipt.qr` (`eims-receipt-document.mapper.ts`) — same convention, + * same gateway. + */ +export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`; + /** One billed line on the document (charge type / fee type agnostic). */ export interface InvoiceDocumentLine { description: string | null; 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 c02c7870e..7d417551f 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 @@ -1,8 +1,10 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common"; +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +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 { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; @@ -110,4 +112,16 @@ export class EimsInvoiceController { listReceipts(@Param("id", ParseUUIDPipe) id: string) { return this.receipts.listReceipts(id); } + + @Get(":id/eims/receipts/:receiptId/document") + @BookingStaff(FREIGHT_PERMS.invoices.export) + @ApiOperation({ summary: "Download the sealed receipt PDF (RRN + QR) for a filed EIMS receipt" }) + async receiptDocument( + @Param("id", ParseUUIDPipe) id: string, + @Param("receiptId", ParseUUIDPipe) receiptId: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.receipts.document(id, receiptId); + sendPdf(res, filename, buffer); + } } diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts new file mode 100644 index 000000000..841bfcc54 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts @@ -0,0 +1,107 @@ +import { Invoice } from "../billing/entities/invoice.entity"; +import { + InvoiceDocumentModel, + pngDataUrl, +} from "../billing/documents/invoice-document.service"; +import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity"; +import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types"; + +/** + * Maps a filed `EimsReceipt` onto the shared invoice/receipt document layout — mirrors + * `eims-invoice.mapper.ts`'s role for `/v1/register`: a pure function, no I/O. + * + * The amounts (collected amount, mode of payment, withholding amount) live only in + * `receipt.request` — the exact body this app sent, typed and written in exactly one place + * (`EimsReceiptService`). Reading it back is a cast, not a new source of truth; real columns + * would mean a migration + backfill for data already present in a stable shape. + * + * Throws rather than returning a model for anything not actually filed: a sealed, stamped PDF + * for a receipt MoR rejected, never acknowledged, or whose request was somehow never recorded + * would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw + * surface as a 400 — there is nothing sensible to render instead. + */ +export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel { + if (receipt.status !== EimsReceiptStatus.Registered) { + throw new Error( + `Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`, + ); + } + if (!receipt.request) { + throw new Error(`Receipt ${receipt.receiptNumber} has no stored request body — cannot render its amounts.`); + } + + const isSales = receipt.kind === "SALES"; + + if (isSales) { + const req = receipt.request as unknown as EimsSalesReceiptRequest; + return build(receipt, invoice, { + title: "Sales Receipt", + currency: req.ReceiptCurrency, + amountLabel: "Collected", + lineDescription: `Payment received against invoice ${invoice.invoiceNumber}`, + amount: req.CollectedAmount, + // A sales receipt is a real payment — this is the one case the shared layout's own default + // ("EDR PAID" for kind RECEIPT) is already correct, but set it explicitly so it never drifts + // if that default changes for an unrelated reason. + sealText: "EDR PAID", + extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }], + }); + } + + const req = receipt.request as unknown as EimsWithholdReceiptRequest; + return build(receipt, invoice, { + title: "Withholding Receipt", + currency: req.InvoiceDetail.Currency, + amountLabel: "Withheld", + lineDescription: `Withholding (${req.WithholdDetail.Type}) against invoice ${invoice.invoiceNumber}`, + amount: req.WithholdDetail.WithholdingAmount, + // A withholding receipt is not a payment — the shared layout's "EDR PAID" default would be + // wrong here, so this is the one case that MUST override it. + sealText: "EDR", + extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }], + }); +} + +function build( + receipt: EimsReceipt, + invoice: Invoice, + opts: { + title: string; + currency: string; + amountLabel: string; + lineDescription: string; + amount: number; + sealText: string; + extraSummary: Array<{ label: string; value: string | null }>; + }, +): InvoiceDocumentModel { + return { + kind: "RECEIPT", + title: opts.title, + documentNumber: receipt.receiptNumber, + issuedAt: receipt.submittedAt ?? null, + status: receipt.status, + currency: opts.currency, + summary: [ + { label: "Invoice", value: invoice.invoiceNumber }, + { label: "Invoice IRN", value: invoice.eimsIrn ?? null }, + { label: "RRN", value: receipt.rrn ?? null }, + { label: "Ack status", value: receipt.ackStatus ?? null }, + ...opts.extraSummary, + ], + // No line items on a receipt — one synthetic line, since buildHtml renders the line table + // unconditionally and an empty `lines: []` would print a header-only empty table. + lines: [ + { + description: opts.lineDescription, + quantity: 1, + unitRate: opts.amount, + amount: opts.amount, + currency: opts.currency, + }, + ], + totals: [{ label: opts.amountLabel, amount: opts.amount, grand: true }], + sealText: opts.sealText, + qrImageUrl: receipt.qr ? pngDataUrl(receipt.qr) : null, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts index b9b59cc27..93f50e827 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts @@ -4,6 +4,7 @@ import { DataSource } from "typeorm"; import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; +import { InvoiceDocumentService } from "../billing/documents/invoice-document.service"; import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; @@ -40,10 +41,16 @@ class FakeDb { } private manager = { - findOne: async (entity: unknown, options: { where: { id: string } }) => - entity === Invoice - ? (this.invoices.get(options.where.id) ?? null) - : (this.receipts.get(options.where.id) ?? null), + findOne: async ( + entity: unknown, + options: { where: { id?: string; invoiceId?: string } }, + ) => { + if (entity === Invoice) return this.invoices.get(options.where.id!) ?? null; + const receipt = options.where.id ? this.receipts.get(options.where.id) : undefined; + if (!receipt) return null; + if (options.where.invoiceId && receipt.invoiceId !== options.where.invoiceId) return null; + return receipt; + }, find: async (_entity: unknown, options: { where: { invoiceId: string } }) => [...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId), save: async (_entity: unknown, data: Record) => { @@ -71,6 +78,7 @@ const build = ( db: FakeDb, postBearer: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined), + documents: { render: jest.Mock } = { render: jest.fn() }, ) => new EimsReceiptService( db.asDataSource(), @@ -78,6 +86,7 @@ const build = ( { postBearer } as unknown as EimsClientService, { getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService, { directSend } as unknown as NotificationsService, + documents as unknown as InvoiceDocumentService, ); const okResponse = (over: Record = {}) => ({ @@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => { expect(list).toHaveLength(2); }); }); + +describe("EimsReceiptService.document", () => { + it("renders a sealed PDF for a registered sales receipt, with RRN and QR in the model", async () => { + const db = new FakeDb([invoiceRow()]); + const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) }; + const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents); + const receipt = await service.registerSalesReceipt(INVOICE_ID, { + modeOfPayment: "CASH", + collectedAmount: 500, + } as never); + + await service.document(INVOICE_ID, receipt.id); + + expect(documents.render).toHaveBeenCalledTimes(1); + const model = documents.render.mock.calls[0][0]; + expect(model.kind).toBe("RECEIPT"); + expect(model.qrImageUrl).toBe("data:image/png;base64,iVBORw0KGgo..."); + expect(model.summary).toContainEqual({ label: "RRN", value: "rrn-value" }); + expect(model.lines[0].amount).toBe(500); + expect(model.sealText).toBe("EDR PAID"); + }); + + it("renders a withholding receipt with the withheld amount and a non-PAID seal", async () => { + const db = new FakeDb([invoiceRow()]); + const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) }; + const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents); + const receipt = await service.registerWithholdingReceipt(INVOICE_ID, { + type: "TWHT", + preTaxAmount: 1000, + withholdingAmount: 20, + } as never); + + await service.document(INVOICE_ID, receipt.id); + + const model = documents.render.mock.calls[0][0]; + expect(model.lines[0].amount).toBe(20); + expect(model.sealText).toBe("EDR"); + expect(model.sealText).not.toContain("PAID"); + }); + + it("refuses to render a receipt that was never acknowledged by MoR", async () => { + const db = new FakeDb([invoiceRow()]); + const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out")); + const documents = { render: jest.fn() }; + const service = build(db, postBearer, undefined, documents); + await expect( + service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never), + ).rejects.toBeInstanceOf(EimsApiException); + const [receipt] = [...db.receipts.values()]; + + await expect(service.document(INVOICE_ID, receipt.id as string)).rejects.toBeInstanceOf(BadRequestException); + expect(documents.render).not.toHaveBeenCalled(); + }); + + it("scopes the lookup to the given invoice — a receipt from another invoice is not found", async () => { + const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222"; + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); + const service = build(db, jest.fn().mockResolvedValue(okResponse())); + const receipt = await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never); + + await expect(service.document(OTHER_INVOICE_ID, receipt.id)).rejects.toThrow(/not found/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts index dfbbd688b..2bdb6cbe2 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts @@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; +import { InvoiceDocumentService } from "../billing/documents/invoice-document.service"; import { NotificationsService } from "../notifications/notifications.service"; import { sendCompanyChannels } from "../notifications/notify-company.util"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; +import { toReceiptDocumentModel } from "./eims-receipt-document.mapper"; import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; @@ -52,6 +54,7 @@ export class EimsReceiptService { private readonly client: EimsClientService, private readonly auth: EimsAuthService, private readonly notifications: NotificationsService, + private readonly documents: InvoiceDocumentService, ) {} private get cfg(): EimsConfig { @@ -154,6 +157,31 @@ export class EimsReceiptService { }); } + /** + * Sealed PDF for one filed receipt (RRN + QR), scoped to the invoice it belongs to. Not on + * `loadRegisteredInvoice` — a receipt refused/never-acknowledged by MoR must not render as a + * sealed tax document, and `toReceiptDocumentModel` is the one place that guards it. + */ + async document(invoiceId: string, receiptId: string): Promise<{ filename: string; buffer: Buffer }> { + const receipt = await this.dataSource.manager.findOne(EimsReceipt, { + where: { id: receiptId, invoiceId }, + }); + if (!receipt) throw new NotFoundException(`Receipt ${receiptId} not found on invoice ${invoiceId}`); + const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + + let model: ReturnType; + try { + model = toReceiptDocumentModel(receipt, invoice); + } catch (err) { + // Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 — + // a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService + // itself throws. + throw new BadRequestException((err as Error).message); + } + return this.documents.render(model); + } + // ── internals ──────────────────────────────────────────────────────────────────────────────── private async submit( diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 7aa59b3ef..0ee22ec88 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -3,6 +3,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; +import { DocumentsModule } from "../billing/documents/documents.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { EimsAuthService } from "./eims-auth.service"; @@ -29,6 +30,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]), NotificationInboxModule, NotificationsModule, + // For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain + // deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle. + DocumentsModule, ], controllers: [EimsInvoiceController], providers: [ From 47c2e8283936e9a43bcfda418b1cfcfb29cf2548 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 15 Aug 2026 06:45:00 +0000 Subject: [PATCH 22/28] feat(freight-backoffice): EIMS cancel/receipt/memo actions in filing card Adds cancel, sales/withholding receipt filing + listing + PDF download, and credit/debit memo issuance to EimsFilingCard, each gated on its own permission. Co-Authored-By: Claude Sonnet 5 --- .../components/invoices/EimsFilingCard.tsx | 495 ++++++++++++++++-- .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../backoffice/src/constants/URLS.ts | 7 + .../backoffice/src/lib/permissions.ts | 10 +- .../backoffice/src/services/api.ts | 47 +- .../backoffice/src/services/eims.service.ts | 48 +- .../src/services/invoices.service.ts | 10 + .../backoffice/src/types/eims.ts | 13 + 8 files changed, 593 insertions(+), 38 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx index ba507513c..c668c0570 100644 --- a/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx @@ -1,11 +1,30 @@ -import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useState } from "react"; +import { + Alert, + Badge, + Button, + Card, + Group, + Modal, + NumberInput, + Radio, + Select, + SimpleGrid, + Stack, + Table, + Text, + Textarea, + TextInput, +} from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react"; +import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; -import type { EimsInvoiceStatus } from "@/types/eims"; +import { eimsService } from "@/services/eims.service"; +import { openPdfBlob } from "@/components/warehouses/pdf"; +import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims"; import { useToast } from "@/hooks/use-toast"; const STATUS_COLOR: Record = { @@ -14,6 +33,7 @@ const STATUS_COLOR: Record = { REGISTERED: "edr-green", FAILED: "red", UNKNOWN: "orange", + CANCELLED: "gray", }; const STATUS_LABEL: Record = { @@ -22,6 +42,7 @@ const STATUS_LABEL: Record = { REGISTERED: "Filed", FAILED: "Rejected", UNKNOWN: "Unacknowledged", + CANCELLED: "Cancelled", }; function Field({ label, value }: { label: string; value?: string | number | null }) { @@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null ); } +/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */ +const CANCEL_REASON_CODES = [ + { value: "1", label: "1 — Duplicate" }, + { value: "2", label: "2 — Buyer request" }, + { value: "3", label: "3 — Data entry error" }, + { value: "6", label: "6 — Calculation error" }, +]; + +function CancelModal({ + invoiceId, + opened, + onClose, +}: { + invoiceId: string; + opened: boolean; + onClose: () => void; +}) { + const { toast } = useToast(); + const [reasonCode, setReasonCode] = useState(null); + const [remark, setRemark] = useState(""); + + const cancel = useMutation( + api.invoices.eimsCancel.mutationOptions({ + onSuccess: () => { + onClose(); + toast({ title: "Cancelled with MoR" }); + }, + onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }), + }), + ); + + return ( + + + + Cancels this invoice's registered document at MoR. Irreversible — an already-cancelled + invoice refuses a second attempt. + +