diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fcd560a95..72ad6de66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,13 +170,36 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail + IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}" docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + # Tag with git SHA for rollback capability + CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1) + docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true + echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}" - name: Deploy ${{ matrix.service }} run: | set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate + - name: Verify deployment health + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) + echo "Waiting for service to become healthy on port ${PORT}..." + for i in $(seq 1 12); do + if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then + echo "Service is healthy." + exit 0 + fi + echo "Attempt ${i}/12 — not ready yet, waiting 10s..." + sleep 10 + done + echo "Service failed health check after 120s — rolling back" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true + exit 1 + - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 2818dcd97..463cea535 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:->/tcp` with matching ports. ### Runtime -The final image runs: +The final image uses Next.js `output: 'standalone'` and runs: ```bash -npx next start +node server.js ``` -Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on. +Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`. + +## Rollback Procedure + +Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-:`). + +### Rollback a single service + +```bash +# 1. Find the last known-good image tag +docker images | grep passenger-api + +# 2. Re-tag it as the current image +docker tag edr-passenger-main-passenger-api: edr-passenger-main-passenger-api:latest + +# 3. Restart the container from the previous image +docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate +``` + +### Rollback via re-run + +Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit. + +## Production Security Checklist + +Before deploying to production, verify: + +- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`) +- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10` +- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production) +- [ ] `NODE_ENV=production` is set +- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token +- [ ] No `.env` files are committed to the repository (`git status` should show none) + +## Data Retention Policy + +The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes: + +| Table | Retention | +|---|---| +| `OtpCode` | 1 hour after expiry or verification | +| `FaydaVerificationSession` | 1 hour after expiry or completion | +| `AuditLog` | 365 days | +| `PaymentWebhookEvent` | 90 days | +| `GateValidationLog` | 180 days | + +No manual intervention is required. Monitor the `TasksService` log output for purge counts. ## GitHub Actions Deployment Flow @@ -147,9 +193,10 @@ For each service: - Computes branch slug and sets: - `COMPOSE_PROJECT_NAME=-` - Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`. -- Runs: - - `docker compose --project-name "$COMPOSE_PROJECT_NAME" build ` - - `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d ` +- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image. +- Builds the service image and tags it with the short git SHA. +- Runs `docker compose up -d --force-recreate`. +- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy. - Cleans `.npmrc`/`.npmrc_temp`. ## Branch/Environment Isolation diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index e02391ccd..1ed8ff9fc 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -55,8 +55,10 @@ REDIS_HOST=localhost REDIS_PORT=6379 # --- Notification broker (RabbitMQ) --------------------------------------------- -# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service). -# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker). +# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared +# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely +# (dev without a local broker). RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue +EMAIL_QUEUE=email_queue diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 8d55f1dc4..7eae4e94d 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); + +/** Container allocation on a booking (allocate-containers endpoint). */ +export const AllocationManage = () => + BookingStaff(FREIGHT_PERMS.allocation.manage); diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts new file mode 100644 index 000000000..1bd3bbc27 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Support email as a second OTP channel alongside phone (e.g. signup lets the + * user choose which one to verify). `phone` becomes nullable since an + * email-channel row has none, and `email` is added as a nullable unique column + * mirroring `phone`'s shape. + */ +export class AddEmailToOtpVerifications1900000000000 + implements MigrationInterface +{ + name = "AddEmailToOtpVerifications1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + DROP COLUMN IF EXISTS email + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone SET NOT NULL + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts new file mode 100644 index 000000000..7a661bc7c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts @@ -0,0 +1,126 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Simplify the rate + weight-limit configuration model: + * + * 1. Drop the effective_from / effective_to validity window from both + * `rates` and `weight_limit_rules`. Rates are now activated purely by + * the approval workflow (status = LIVE) and weight limits are always + * active for their container + direction. No time-travel scheduling. + * + * 2. Enforce "one rate per pattern" with partial unique indexes so the same + * configuration (e.g. FIRST_MILE for a given container type) cannot be + * duplicated. NULL scope columns are COALESCE-normalised because Postgres + * treats NULLs as distinct in a plain unique index. + * + * This migration is destructive on the date columns — existing effective_* + * values are dropped. + */ +export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface { + name = 'SimplifyRatesAndWeightLimitRules1900000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. De-duplicate existing data so the unique indexes can be created ── + // Keep the most recently-created row per pattern, soft-delete the rest. + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.rates + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED' + ) + UPDATE freight.rates r + SET deleted_at = now() + FROM ranked + WHERE r.id = ranked.id AND ranked.rn > 1; + `); + + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY container_type_id, trade_direction + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.weight_limit_rules + WHERE deleted_at IS NULL + ) + UPDATE freight.weight_limit_rules w + SET deleted_at = now() + FROM ranked + WHERE w.id = ranked.id AND ranked.rn > 1; + `); + + // ── 2. Drop the effective-date indexes + columns ─────────────────────── + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`); + // Indexes created by TypeORM's @Index carry generated hashed names — drop + // any index that references the effective_from column defensively. + await queryRunner.query(` + DO $$ + DECLARE idx record; + BEGIN + FOR idx IN + SELECT indexname FROM pg_indexes + WHERE schemaname = 'freight' + AND tablename IN ('rates', 'weight_limit_rules') + AND indexdef ILIKE '%effective_from%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname); + END LOOP; + END $$; + `); + + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`); + + // ── 3. One-rate-per-pattern partial unique indexes ───────────────────── + // The unit is part of the identity so a surcharge can legitimately carry two + // rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON), + // while still blocking a true duplicate (same rateType + scope + unit). + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern" + ON freight.weight_limit_rules (container_type_id, trade_direction) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`); + + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`); + await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`); + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`, + ); + } +} 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 7da5e2d66..b9f0a74c0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,21 +1,31 @@ -import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Res, +} from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; +import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@FreightAdmin() +@BookingView() @ApiBearerAuth() export class BillingController { - constructor(private readonly billingService: BillingService) { } + constructor(private readonly billingService: BillingService) {} @Get("invoices") - @ApiOperation({ summary: "List all invoices" }) - findAll() { - return this.billingService.findAll(); + @ApiOperation({ + summary: "List invoices (paginated, filterable by company/status/search)", + }) + findAll(@Query() query: FilterInvoiceDto) { + return this.billingService.findAllPaginated(query); } @Get("invoices/:id") 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 82a47fa29..536129122 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -125,7 +125,7 @@ export class BillingService { private readonly payment: PaymentService, private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, - ) { } + ) {} // ── Reads ────────────────────────────────────────────────────────────────── @@ -134,9 +134,56 @@ export class BillingService { return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } + /** + * Paginated invoice list for the backoffice — optionally narrowed to a + * company (customer detail "Invoices" tab) and/or status/search (global + * invoices page). + */ + async findAllPaginated( + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + page?: number; + pageSize?: number; + } = {}, + ): Promise<{ items: Invoice[]; total: number }> { + const page = filter.page && filter.page > 0 ? filter.page : 1; + const pageSize = + filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; + + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .leftJoinAndSelect("invoice.company", "company") + .orderBy("invoice.issuedAt", "DESC") + .skip((page - 1) * pageSize) + .take(pageSize); + + if (filter.companyId) { + qb.andWhere("invoice.companyId = :companyId", { + companyId: filter.companyId, + }); + } + if (filter.status) { + qb.andWhere("invoice.status = :status", { status: filter.status }); + } + if (filter.search) { + qb.andWhere( + "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + { search: `%${filter.search}%` }, + ); + } + + const [items, total] = await qb.getManyAndCount(); + return { items, total }; + } + /** Invoice header plus its line items. */ async findById(id: string): Promise { - const invoice = await this.invoices.findById(id); + const invoice = await this.invoices.findById(id, { + relations: { company: true, companyProfile: true }, + }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const lines = await this.invoiceLines.findAll({ where: { invoiceId: id }, @@ -375,7 +422,7 @@ export class BillingService { input.dueAt ?? new Date( Date.now() + - (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); const invoiceNumber = await this.nextInvoiceNumber(mg); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts new file mode 100644 index 000000000..e8942d586 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -0,0 +1,42 @@ +import { Freight } from "@edr/types"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Min, +} from "class-validator"; + +export class FilterInvoiceDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + pageSize?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + companyId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: Freight.InvoiceStatus }) + @IsOptional() + @IsIn(Object.values(Freight.InvoiceStatus)) + status?: Freight.InvoiceStatus; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts index cfb9887c3..bb159c538 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { AllocateContainersDto } from './dto/allocate-containers.dto'; +import { AllocationManage } from '../../common/booking-guards'; @ApiTags('bookings') @Controller('bookings') @@ -10,6 +11,7 @@ export class BookingAllocationController { constructor(private readonly bookingsService: BookingsService) {} @Post(':bookingId/allocate-containers') + @AllocationManage() @ApiOperation({ summary: 'Allocate containers to vehicles' }) async allocateContainers( @Param('bookingId', ParseUUIDPipe) bookingId: string, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 1c1b490dd..db6b70eae 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, ratesService as never, exchangeService as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index c5e5b710e..e8469e627 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -17,6 +17,14 @@ import { import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { ContainerValidationService } from './container-validation.service'; + +export interface OverweightLine { + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; +} export interface ComputedPriceResult { lineItems: PriceLineItemDto[]; @@ -27,6 +35,7 @@ export interface ComputedPriceResult { priorityScore: number; warnings: string[]; hardBlocked: string[]; + overweightLines: OverweightLine[]; } type StoredPricingBreakdown = { @@ -67,6 +76,7 @@ export class BookingPricingService { private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly exchangeService: ExchangeService, + private readonly containerValidationService: ContainerValidationService, ) {} async generatePrice(bookingId: string): Promise { @@ -94,12 +104,19 @@ export class BookingPricingService { }, } as never); + // 20ft weight-pairing preview: surfaced now so the customer sees the problem + // (and the overweight warning + surcharge) at the confirm step, before submit. + // Submit re-runs this and HARD-BLOCKS on a non-empty result. + const pairing = await this.containerValidationService.validate20ftPairing(booking); + return { bookingId, totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, warnings: computed.warnings, + overweightLines: computed.overweightLines, + pairingErrors: pairing.map((p) => p.message), }; } @@ -169,6 +186,35 @@ export class BookingPricingService { if (rate) usedRatesMap.set(rate.id, rate); } + // Overweight detail for the customer: map the engine's per-line results back + // to the booking's container lines (same order) for code + weights. maxAllowed + // is derived from the line total minus the excess the engine computed. + const overweightLines: OverweightLine[] = []; + const containerLines = (booking.bookingContainers ?? []).filter( + (bc) => bc.containerTypeId != null, + ); + for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { + const wr = ruleResult.containerWeightResults[i]; + if (!wr?.isOverweight) continue; + const line = containerLines[i]; + const totalVgmTons = Number(line?.totalVgmTons ?? 0); + const excessTons = Number(wr.overweightExcessTons ?? 0); + let code = line?.containerSize ?? ''; + if (line?.containerTypeId) { + try { + code = (await this.containerTypesService.findById(line.containerTypeId)).code; + } catch { + // fall back to the container size label + } + } + overweightLines.push({ + containerTypeCode: code, + totalVgmTons, + maxAllowedTons: Math.max(0, totalVgmTons - excessTons), + excessTons, + }); + } + return { lineItems, totalAmount: total, @@ -178,6 +224,7 @@ export class BookingPricingService { priorityScore: ruleResult.priorityScore, warnings: ruleResult.warnings, hardBlocked: ruleResult.hardBlocked, + overweightLines, }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index f9b160672..3c535f450 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index d62883d53..9f9aa5713 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } @@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } @@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 66df02ac4..ea3618a08 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, bookingBatchService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 031e521a9..3e1ea3cd4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { clearanceCodesForBooking } from './clearance.util'; @@ -51,6 +52,7 @@ export class BookingTransitionService { @Inject(forwardRef(() => ClearanceWorkflowService)) private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, + private readonly containerValidationService: ContainerValidationService, ) {} @@ -58,6 +60,19 @@ export class BookingTransitionService { return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); } + /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ + private async assert20ftPairable(booking: Booking): Promise { + const violations = + await this.containerValidationService.validate20ftPairing(booking); + if (violations.length) { + throw new BadRequestException( + `Cannot submit — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } + } + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); @@ -78,6 +93,11 @@ export class BookingTransitionService { requiresDirectorApproval: false, }); + // 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap. + // If no balanced pairing exists the booking cannot proceed (overweight only + // warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation. + await this.assert20ftPairable(booking); + const stored = booking.pricingBreakdown as { lineItems?: PriceLineItemDto[]; totalAmount?: number; @@ -158,6 +178,7 @@ export class BookingTransitionService { hardBlocked: computed.hardBlocked, requiresDirectorApproval: false, }); + await this.assert20ftPairable(booking); await this.pricingService.createPricingSnapshots( bookingId, @@ -993,12 +1014,14 @@ export class BookingTransitionService { // Export is FCFS: fail the accept up-front (409) when no export train on the // booking's day still has capacity — nothing below runs and the request stays - // pending for staff to move/decline. + // pending for staff to move/decline. (For a consolidated pair this is a rough + // solo pre-check; the real combined-capacity reservation happens after the + // booking is FULLY_EXECUTED, once both partners are ready.) const isExportTrain = booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); - const exportScheduleId = isExportTrain - ? await this.bookingBatchService.pickExportSchedule(booking) - : null; + if (isExportTrain) { + await this.bookingBatchService.pickExportSchedule(booking); + } const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( @@ -1023,11 +1046,12 @@ export class BookingTransitionService { lockedAt: booking.lockedAt ?? now, } as never); - if (exportScheduleId) { + if (isExportTrain) { // FCFS: reserve the slot and send the payment notification immediately; - // paid → auto-allocated by the settle/paid pipeline. + // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings + // only reserve once both partners are FULLY_EXECUTED (handled inside). const fresh = await this.bookingsService.findById(booking.id); - await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId); + await this.bookingBatchService.acceptExportBooking(fresh); } else if (booking.tradeDirection === "IMPORT") { // Import bookings wait for their booking-day window cycle — the batch runs // after staff document review, never at accept time. diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index b54b6b49e..92790c273 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -79,6 +80,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsService, BookingsRepository, ConsolidationService, + ContainerValidationService, BookingReferenceDataService, BookingPricingService, BookingTransitionService, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b99611c35..3f696bc6b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository { /** * Find another booking whose container quantity complements this one to fill whole wagon(s) - * (same route, same container type, partial wagon on both sides). + * (same route, same container type, partial wagon on both sides). Only 20ft lines ever + * reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial. + * + * Partners must also ride the SAME booking day: consolidation shares one physical wagon, + * and the window/batch pool is keyed on the EAT departure day, so a pair that can't board + * the same train is useless. The day filter is applied only when THIS booking already has + * a scheduled_date (draft bookings without a date match on route/type alone until they pick one). */ async findComplementaryConsolidationPartner( booking: Booking, @@ -198,7 +204,7 @@ export class BookingsRepository extends BaseRepository { ): Promise { const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; - return this.repository + const qb = this.repository .createQueryBuilder('b') .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') @@ -224,9 +230,18 @@ export class BookingsRepository extends BaseRepository { .andWhere('((:quantity + bc.quantity) % :perWagon) = 0', { quantity, perWagon, - }) - .orderBy('b.createdAt', 'ASC') - .getOne(); + }); + + // Same EAT booking day, so the pair can share a wagon on one train. Skip only + // when this booking has no date yet (matched again once it picks its day). + if (booking.scheduledDate) { + qb.andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: booking.scheduledDate }, + ); + } + + return qb.orderBy('b.createdAt', 'ASC').getOne(); } /** Try each partial-wagon line until a complementary partner booking is found. */ diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts new file mode 100644 index 000000000..6aed9bf1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts @@ -0,0 +1,55 @@ +import { validate20ftWeightPairing } from './container-pairing.util'; + +describe('validate20ftWeightPairing', () => { + const MAX_DIFF = 10; + + it('passes when a balanced pairing exists (adjacent diffs within cap)', () => { + // sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10. + const units = [ + { label: 'A', grossWeightTons: 24 }, + { label: 'B', grossWeightTons: 8 }, + { label: 'C', grossWeightTons: 18 }, + { label: 'D', grossWeightTons: 15 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('flags a pair whose weight difference exceeds the cap', () => { + // sorted: 5, 25 → single pair diff 20 > 10. + const units = [ + { label: 'HEAVY', grossWeightTons: 25 }, + { label: 'LIGHT', grossWeightTons: 5 }, + ]; + const result = validate20ftWeightPairing(units, MAX_DIFF); + expect(result).toHaveLength(1); + expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']); + expect(result[0].diffTons).toBe(20); + }); + + it('allows an odd leftover unit (goes to consolidation, not a violation)', () => { + // sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover. + const units = [ + { label: 'A', grossWeightTons: 10 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 30 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => { + // Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail), + // but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation. + const units = [ + { label: 'A', grossWeightTons: 20 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 22 }, + { label: 'D', grossWeightTons: 10 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('returns nothing for fewer than two units', () => { + expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]); + expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts new file mode 100644 index 000000000..cf1cfe944 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts @@ -0,0 +1,64 @@ +/** + * Booking-time 20ft weight-pairing rule. + * + * A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the + * same wagon their gross-weight difference must not exceed `maxPairDiffTons` + * (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays + * balanced. 40ft containers occupy a whole wagon alone and never pair. + * + * At booking time the customer enters every 20ft container's weight but not its + * wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent + * (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY + * valid pairing exists this one finds it — a violation here means no balanced + * pairing is possible and the booking must be blocked. An odd leftover 20ft is + * fine: it has no partner in this booking and flows to consolidation. + */ + +export interface Container20ftUnit { + /** Human label for messages, e.g. the container number. */ + label: string; + grossWeightTons: number; +} + +export interface PairingViolation { + message: string; + /** The two container labels whose pairing exceeds the diff cap. */ + labels: [string, string]; + diffTons: number; +} + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Validate that the given 20ft units can all be paired onto wagons within the + * weight-difference cap. Returns one violation per over-cap adjacent pair (empty + * when every wagon pair is balanced or there is nothing to pair). A single + * leftover unit (odd count) is not a violation. + */ +export function validate20ftWeightPairing( + units: Container20ftUnit[], + maxPairDiffTons: number, +): PairingViolation[] { + if (units.length < 2 || maxPairDiffTons == null) return []; + + // Ascending by weight: adjacent pairs have the smallest possible diffs. + const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons); + const violations: PairingViolation[] = []; + + for (let i = 0; i + 1 < sorted.length; i += 2) { + const a = sorted[i]; + const b = sorted[i + 1]; + const diff = Math.abs(a.grossWeightTons - b.grossWeightTons); + if (diff > maxPairDiffTons) { + violations.push({ + message: + `20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` + + `${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` + + `weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`, + labels: [a.label, b.label], + diffTons: round2(diff), + }); + } + } + return violations; +} diff --git a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts new file mode 100644 index 000000000..de4dca661 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, In } from 'typeorm'; + +import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { Booking } from './entities/booking.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; +import { + Container20ftUnit, + PairingViolation, + validate20ftWeightPairing, +} from './container-pairing.util'; + +/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */ +const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10; + +/** + * Booking-time container validations that need the customer-entered per-unit + * weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the + * rule engine (which works on line totals) because pairing is per physical unit. + */ +@Injectable() +export class ContainerValidationService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + private async maxPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const v = row?.max20ftPairWeightDiffTons; + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS; + } + + /** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */ + private async load20ftUnits(booking: Booking): Promise { + const lines = (booking.bookingContainers ?? []).filter( + (bc) => (bc.containerSize ?? '').includes('20'), + ); + if (!lines.length) return []; + + const units = await this.dataSource + .getRepository(BookingContainerUnit) + .find({ + where: { bookingContainerId: In(lines.map((l) => l.id)) }, + order: { sortOrder: 'ASC' }, + }); + + return units.map((u) => ({ + label: u.containerNumber || u.id.slice(0, 8), + grossWeightTons: Number(u.vgmTons ?? 0), + })); + } + + /** + * Validate the 20ft weight-pairing rule for a booking. Returns one message per + * pair whose weight difference exceeds the cap; empty when all 20ft can be + * balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine — + * it flows to consolidation. Callers hard-block a non-empty result. + */ + async validate20ftPairing(booking: Booking): Promise { + // Only bookings whose 20ft lines actually carry per-unit weights can be + // checked; contract-drawdown bookings do (units are required there). + const containerLines = booking.bookingContainers ?? []; + const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); + if (!has20ft) return []; + + const units = await this.load20ftUnits(booking); + if (units.length < 2) return []; + + const maxDiff = await this.maxPairDiffTons(); + return validate20ftWeightPairing(units, maxDiff); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 532d6b1a7..0ee77aeed 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -27,6 +27,20 @@ export class PriceLineItemDto { currency!: string; } +export class OverweightLineDto { + @ApiProperty() + containerTypeCode!: string; + + @ApiProperty() + totalVgmTons!: number; + + @ApiProperty() + maxAllowedTons!: number; + + @ApiProperty() + excessTons!: number; +} + export class GeneratePriceResponseDto { @ApiProperty() bookingId!: string; @@ -42,4 +56,16 @@ export class GeneratePriceResponseDto { @ApiProperty({ type: [String] }) warnings!: string[]; + + /** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */ + @ApiProperty({ type: [OverweightLineDto] }) + overweightLines!: OverweightLineDto[]; + + /** + * 20ft weight-pairing violations. Non-empty means the booking cannot be + * balanced onto wagons and submit is HARD-BLOCKED — the customer must fix + * container weights/quantities. (Overweight, by contrast, only warns.) + */ + @ApiProperty({ type: [String] }) + pairingErrors!: string[]; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index b0a5cc636..01b35fc71 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1,11 +1,14 @@ import { BadRequestException, ForbiddenException, + Inject, Injectable, Logger, NotFoundException, + forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { ExchangeService } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -13,6 +16,9 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; +import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -60,6 +66,9 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, + private readonly exchangeService: ExchangeService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, ) {} async createUnderContract( @@ -111,6 +120,20 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // Booking-window gate (config-driven): an operations booking may only be + // created while the route's booking window is open — import: the day's window + // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); + // export: within exportBookingLeadHours of departure. Customs Path B bookings + // enter clearance first and are scheduled later, so they are not gated here. + if (!generalCustoms) { + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + scheduledDate: dto.scheduledDate ?? null, + direction: contract.tradeDirection ?? null, + }); + } + // Denormalize route/direction/freight onto the booking for the scheduling engine. const booking = await this.bookingsRepository.create({ reference, @@ -563,6 +586,145 @@ export class ContractBookingService { } } + /** + * Pre-create validation for the shipment form: run the overweight rule + the + * 20ft weight-pairing rule against the entered containers WITHOUT persisting a + * booking. The portal calls this from the price-confirm modal so the customer + * sees the overweight warning (+ surcharge basis) and is blocked on an + * un-pairable 20ft set before the booking is created. + */ + async validateShipment( + contractId: string, + dto: CreateBookingUnderContractDto, + ): Promise<{ + overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }>; + overweightSurchargeAmount: number; + currency: string | null; + pairingErrors: string[]; + }> { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + + const lines = dto.containers ?? []; + if (!lines.length) { + return { + overweightLines: [], + overweightSurchargeAmount: 0, + currency: null, + pairingErrors: [], + }; + } + + // Resolve each line's container type + total VGM (sum of unit weights) so the + // rule engine can flag overweight per line (maxVgmTons × quantity vs total). + const resolved = await Promise.all( + lines.map(async (line) => { + const ct = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + const totalVgmTons = (line.units ?? []).reduce( + (s, u) => s + Number(u.vgmTons ?? 0), + 0, + ); + return { line, ct, totalVgmTons }; + }), + ); + + const ruleResult = await this.ruleEngineService.evaluate({ + freightType: 'CONTAINER', + cargoTypeId: null, + serviceTypeId: contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + tradeDirection: contract.tradeDirection, + isHazardous: false, + isReefer: contract.isReefer ?? false, + isGovernment: false, + allowConsolidation: false, + shippingLineId: null, + totalWagons: 0, + bulkTons: 0, + containers: resolved.map((r) => ({ + containerTypeId: r.ct.id, + quantity: r.line.quantity, + vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0, + totalVgmTons: r.totalVgmTons, + isReefer: r.ct.isReefer, + })), + } as never); + + const overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }> = []; + for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { + const wr = ruleResult.containerWeightResults[i]; + if (!wr?.isOverweight) continue; + const r = resolved[i]; + const excessTons = Number(wr.overweightExcessTons ?? 0); + overweightLines.push({ + containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '', + totalVgmTons: r?.totalVgmTons ?? 0, + maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons), + excessTons, + }); + } + + // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. + const twentyFtUnits = resolved + .filter((r) => (r.line.containerSize ?? '').includes('20')) + .flatMap((r) => + (r.line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + const maxDiff = await this.max20ftPairDiffTons(); + const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map( + (v) => v.message, + ); + + // Real overweight surcharge (same rate the rule engine bills at booking-create + // time) so the confirm-modal total isn't missing the charge the warning refers to. + // Rates are stored in USD; convert to the contract's payment currency the same + // way BookingPricingService does so this preview matches the eventual booking total. + const overweightModifier = ruleResult.appliedModifiers.find( + (m) => m.surchargeCode === 'OVERWEIGHT_PER_TON', + ); + let overweightSurchargeAmount = 0; + if (overweightModifier) { + const isEtb = contract.paymentCurrency === 'ETB'; + const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; + overweightSurchargeAmount = isEtb + ? Math.round(overweightModifier.calculatedAmount * usdToEtb) + : overweightModifier.calculatedAmount; + } + + return { + overweightLines, + overweightSurchargeAmount, + currency: overweightLines.length ? contract.paymentCurrency : null, + pairingErrors, + }; + } + + private async max20ftPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons); + return Number.isFinite(n) ? n : 10; + } + /** Pick the default container type for a size; prefer reefer when requested. */ private async resolveContainerTypeForSize( size: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e87112bc2..9bb4b2de6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; @@ -63,6 +64,7 @@ export class ContractTransitionService { private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, + private readonly otpService: OtpService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -520,6 +522,12 @@ export class ContractTransitionService { if (existing) { throw new BadRequestException('Customer has already signed this contract'); } + // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone) + // must be verified before the signature is applied. + if (!dto.otpPhone || !dto.otp) { + throw new BadRequestException('OTP verification is required to sign the contract'); + } + await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 6ded65ae9..09e4a7ffd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -788,6 +788,18 @@ export class ContractsController { ); } + @Post(':id/validate-shipment') + @ApiOperation({ + summary: + 'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).', + }) + validateShipment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingUnderContractDto, + ) { + return this.contractBookingService.validateShipment(id, dto); + } + @Get(':id/capacity') @ApiOperation({ summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index e0eece986..a9f9dcf5d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -11,7 +11,9 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; +import { OtpModule } from '../otp/otp.module'; import { BookingsModule } from '../bookings/bookings.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -72,10 +74,15 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum FilesModule, MinioModule, SignaturesModule, + OtpModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), + // TrainSchedulingModule provides the config-driven booking-window gate used + // by ContractBookingService.createUnderContract. forwardRef because + // TrainSchedulingModule already imports ContractsModule. + forwardRef(() => TrainSchedulingModule), ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index febe7a83b..f0676b629 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; export class SignContractDto { @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) @@ -26,4 +26,19 @@ export class SignContractDto { @IsOptional() @IsString() consentText?: string; + + // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code + // SMS'd to the signer's phone, verified server-side before the signature is + // applied. `otpPhone` is the number the code was sent to (the signed-in + // customer's registered phone). + @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) + otp?: string; + + @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts new file mode 100644 index 000000000..79a6547bc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class SendEmailDto { + @ApiProperty({ + description: "Recipient email address", + example: "customer@example.com", + }) + @IsEmail() + @IsNotEmpty() + to!: string; + + @ApiProperty({ + description: "Email subject", + example: "Your EDR Freight verification code", + }) + @IsString() + @IsNotEmpty() + subject!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + html?: string; +} diff --git a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts new file mode 100644 index 000000000..161b2486a --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts @@ -0,0 +1,51 @@ +import { + Inject, + Injectable, + Logger, + OnApplicationBootstrap, +} from "@nestjs/common"; +import { ClientProxy } from "@nestjs/microservices"; +import { SendEmailDto } from "./dtos/email.dto"; + +@Injectable() +export class EmailClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(EmailClientService.name); + + constructor( + @Inject("EMAIL_SERVICE") + private readonly emailClient: ClientProxy, + ) {} + + private readonly enabled = process.env.RABBITMQ_ENABLED !== "false"; + + async onApplicationBootstrap() { + if (!this.enabled) return; + this.emailClient + .connect() + .then(() => this.logger.log("connected to Email service")) + .catch((err) => { + console.error("Error happened at Email service", err); + }); + } + + async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> { + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`); + return { queued: false }; + } + this.emailClient.emit("send-email", { + to: dto.to, + subject: dto.subject, + text: dto.text, + html: dto.html, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. + this.logger.log( + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, + ); + // Recipient + content are PII — debug only. + this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`); + return { queued: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 663f931ef..4e56c8b70 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices"; import { NotificationsService } from "./notifications.service"; import { SmsClientService } from "./sms-client.service"; +import { EmailClientService } from "./email-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; @@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" queueOptions: { durable: true }, }, }, + { + name: "EMAIL_SERVICE", + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL as string], + queue: process.env.EMAIL_QUEUE ?? "email_queue", + queueOptions: { durable: true }, + }, + }, ]), ], controllers: [], - providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService], - exports: [NotificationsService, SmsClientService], + providers: [ + EmailNotificationStrategy, + SmsNotificationStrategy, + NotificationsService, + SmsClientService, + EmailClientService, + ], + exports: [NotificationsService, SmsClientService, EmailClientService], }) export class NotificationsModule {} diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 5850cbb1a..155657a74 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -1,15 +1,24 @@ // otp.controller.ts import { + BadRequestException, Body, Controller, Post, } from "@nestjs/common"; -import { OtpService } from "./otp.service"; +import { OtpService, OtpTarget } from "./otp.service"; import { Public } from "@edr/api-common"; +// Exactly one of phone/email must be present per request — the channel the +// code is sent through / checked against. +function toTarget(phone?: string, email?: string): OtpTarget { + if (email) return { email }; + if (phone) return { phone }; + throw new BadRequestException("phone or email is required"); +} + @Controller("otp") @Public() export class OtpController { @@ -24,9 +33,12 @@ export class OtpController { @Post("send") async sendOtp( @Body("phone") - phone: string + phone?: string, + + @Body("email") + email?: string ) { - return this.otpService.sendOtp(phone); + return this.otpService.sendOtp(toTarget(phone, email)); } // --------------------------------------------------------------------------- @@ -36,13 +48,16 @@ export class OtpController { @Post("verify") async verifyOtp( @Body("phone") - phone: string, + phone: string | undefined, + + @Body("email") + email: string | undefined, @Body("otp") otp: string ) { return this.otpService.verifyOtp( - phone, + toTarget(phone, email), otp ); } diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts index f5900f6b8..022bbf767 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.entity.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common"; name: "otp_verifications", }) export class OtpVerification extends BaseEntity{ + // Exactly one of phone/email is set per row — the channel the code was sent + // through. @Column({ unique: true, + nullable: true, }) - phone!: string; + phone?: string; + + @Column({ + unique: true, + nullable: true, + }) + email?: string; @Column() otp!: string; diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts index ec1d9f9ed..511fe4bbb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.module.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module"; exports: [ OtpRepository, + OtpService, ], }) export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts index 8aa69dcd6..7abd434d8 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.repository.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -31,17 +31,44 @@ export class OtpRepository { }); } + // --------------------------------------------------------------------------- + // Find By Email + // --------------------------------------------------------------------------- + + async findByEmail( + email: string + ) { + return this.repository.findOne({ + where: { + email, + }, + }); + } + + // --------------------------------------------------------------------------- + // Find By Target (either channel) + // --------------------------------------------------------------------------- + + async findByTarget( + target: { phone?: string; email?: string } + ) { + return target.email + ? this.findByEmail(target.email) + : this.findByPhone(target.phone!); + } + // --------------------------------------------------------------------------- // Create OTP // --------------------------------------------------------------------------- async createOtp( - phone: string, + target: { phone?: string; email?: string }, otp: string ) { const entity = this.repository.create({ - phone, + phone: target.phone, + email: target.email, otp, verified: false, }); @@ -70,10 +97,10 @@ export class OtpRepository { } // --------------------------------------------------------------------------- - // Verify Phone + // Mark Verified // --------------------------------------------------------------------------- - async verifyPhone( + async markVerified( otpVerification: OtpVerification ) { otpVerification.verified = @@ -83,4 +110,18 @@ export class OtpRepository { otpVerification ); } + + // --------------------------------------------------------------------------- + // Delete OTP (single-use consume) + // --------------------------------------------------------------------------- + + // Hard delete so the unique `phone` row is freed and a fresh code can be + // requested for the same number on the next action. + async deleteOtp( + otpVerification: OtpVerification + ) { + return this.repository.remove( + otpVerification + ); + } } \ No newline at end of file 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 ffa9c4e68..67fbdec9b 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -1,80 +1,80 @@ // otp.service.ts -import { - BadRequestException, - Injectable, -} from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { OtpRepository } from "./otp.repository"; import { SmsClientService } from "../notifications/sms-client.service"; +import { EmailClientService } from "../notifications/email-client.service"; + +// Exactly one of phone/email is set — enforced by the controller before it +// reaches here. +export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { + logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService - ) {} + private readonly smsClient: SmsClientService, + private readonly emailClient: EmailClientService, + ) { } // --------------------------------------------------------------------------- // Generate OTP // --------------------------------------------------------------------------- generateOtp(): string { - return Math.floor( - 100000 + Math.random() * 900000 - ).toString(); + return Math.floor(100000 + Math.random() * 900000).toString(); } // --------------------------------------------------------------------------- // Send OTP // --------------------------------------------------------------------------- - async sendOtp(phone: string) { + async sendOtp(target: OtpTarget) { try { // The verification code is generated server-side — never supplied by the // caller — so the OTP stays a secret known only to the server and the - // recipient of the SMS. + // recipient of the SMS/email. const otp = this.generateOtp(); - // find existing phone - const existingPhone = - await this.otpRepository.findByPhone( - phone - ); + // find existing row for this channel + const existing = await this.otpRepository.findByTarget(target); // update existing otp - if (existingPhone) { - await this.otpRepository.updateOtp( - existingPhone, - otp - ); + if (existing) { + await this.otpRepository.updateOtp(existing, otp); } else { // create new otp - await this.otpRepository.createOtp( - phone, - otp - ); + await this.otpRepository.createOtp(target, otp); } - // send sms (queued to RabbitMQ via the shared SMS service) - await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); + if (target.email) { + // send email (queued to RabbitMQ via the shared Email service) + await this.emailClient.sendEmail({ + to: target.email, + subject: "Your EDR Freight verification code", + text: `Your verification code is ${otp}`, + }); + } else { + // send sms (queued to RabbitMQ via the shared SMS service) + await this.smsClient.sendSms({ + to: target.phone as string, + message: `Your verification code is ${otp}`, + }); + } + this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`); return { success: true, - message: - "OTP sent successfully", + message: "OTP sent successfully", }; } catch (error) { console.log(error); - throw new BadRequestException( - "Failed to send OTP" - ); + throw new BadRequestException("Failed to send OTP"); } } @@ -82,40 +82,70 @@ export class OtpService { // Verify OTP // --------------------------------------------------------------------------- - async verifyOtp( - phone: string, - otp: string - ) { - // find phone - const otpData = - await this.otpRepository.findByPhone( - phone - ); + async verifyOtp(target: OtpTarget, otp: string) { + // find the channel's row + const otpData = await this.otpRepository.findByTarget(target); - // phone not found + // not found if (!otpData) { throw new BadRequestException( - "Phone number not found" + target.email ? "Email address not found" : "Phone number not found", ); } // invalid otp if (otpData.otp !== otp) { - throw new BadRequestException( - "Invalid OTP" - ); + throw new BadRequestException("Invalid OTP"); } - // verify phone - await this.otpRepository.verifyPhone( - otpData - ); + // mark verified + await this.otpRepository.markVerified(otpData); return { success: true, - message: - "Phone verified successfully", + message: target.email + ? "Email verified successfully" + : "Phone verified successfully", }; } -} \ No newline at end of file + + // --------------------------------------------------------------------------- + // Verify OTP for a sensitive action (sudo mode) + // --------------------------------------------------------------------------- + + // Fresh, single-use challenge gating a sensitive action (e.g. applying a + // contract signature). Unlike verifyOtp above — which marks a phone verified + // and leaves the code in place — this enforces a short TTL and consumes the + // code on success so it can never be replayed. + private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; + + async verifyOtpForAction(phone: string, otp: string) { + const otpData = await this.otpRepository.findByPhone(phone); + + if (!otpData) { + throw new BadRequestException( + "No verification code was requested for this phone", + ); + } + + const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); + + if (ageMs > this.ACTION_OTP_TTL_MS) { + await this.otpRepository.deleteOtp(otpData); + + throw new BadRequestException( + "Verification code has expired. Request a new one.", + ); + } + + if (otpData.otp !== otp) { + throw new BadRequestException("Invalid verification code"); + } + + // single-use: consume on success + await this.otpRepository.deleteOtp(otpData); + + return { success: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 57c95eab3..0fc5a6ba5 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -1,9 +1,10 @@ import { - Body, - Controller, - HttpCode, - HttpStatus, - Post, + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { Public } from "@edr/api-common"; @@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service"; @Public() @Controller("internal/payments") export class InternalPaymentController { - constructor(private readonly paymentService: PaymentService) { } + private readonly logger = new Logger(InternalPaymentController.name); + constructor(private readonly paymentService: PaymentService) { } - @Post("mark-paid") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", - }) - async markPaid(@Body() event: PaymentEventDto): Promise { - return this.paymentService.handlePaymentEvent(event); - } + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + this.logger.log(`Marking payment ${event} as PAID`); + return this.paymentService.handlePaymentEvent(event); + } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts index 25c3bdd6b..96a4994ea 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -93,9 +93,8 @@ export class PaymentRepository { p.paid_at, p.created_at FROM freight.payments p - JOIN freight.bookings b ON b.id = p.ref_id + JOIN freight.bookings b ON b.id = p.ref_id::uuid WHERE b.company_id = $1 - AND p.deleted_at IS NULL AND b.deleted_at IS NULL ORDER BY p.created_at DESC`, [companyId], diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 995718135..9a4cd642b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; import { RATE_APPLIES_TO, RATE_TRIGGERS, @@ -51,15 +51,6 @@ export class CreateRateDto { @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) @IsIn([...RATE_UNITS]) rateUnit!: string; - - @ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index 6be37214b..eea223ae3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -21,13 +21,4 @@ export class CreateWeightLimitRuleDto { @Min(0) @Transform(({ value }) => Number(value)) maxVgmTons!: number; - - @ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts new file mode 100644 index 000000000..cef613412 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -0,0 +1,71 @@ +import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; + +/** + * Which rate units make sense for a given rate shape. The weighting basis is + * driven by the *type* of thing being billed — a container leg bills per + * container, bulk freight per ton, an intercity move can be per-km, a + * cancellation is a flat/per-invoice fee, and overweight is always per excess + * ton. This keeps the rate table dynamic yet non-conflicting: the admin can + * only pick a unit the pricing engine knows how to apply. + * + * Returned lists are ordered with the most natural/default unit first. + */ +export function allowedRateUnits(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; +}): RateUnit[] { + const { appliesTo, trigger } = input; + + // Surcharges (Applies to = Other) are governed by their trigger. + if (appliesTo === 'OTHER') { + switch (trigger) { + case 'OVERWEIGHT': + // Overweight always bills the excess tonnage — per ton, nothing else. + return ['PER_TON']; + case 'REEFER': + case 'HAZARDOUS': + // Scale with the freight shape: per container for boxes, per ton for bulk. + return ['PER_CONTAINER', 'PER_TON']; + case 'DEMURRAGE': + return ['PER_CONTAINER', 'PER_TON']; + case 'CANCELLATION': + return ['FLAT', 'PER_INVOICE']; + case 'CONSOLIDATION': + return ['PER_CONTAINER', 'FLAT']; + case 'SHIPPING_LINE': + case 'PIL_EXTRA_FEE': + return ['PER_CONTAINER', 'FLAT']; + default: + return ['FLAT', 'PER_TON', 'PER_CONTAINER']; + } + } + + // Base freight + first/last mile scale with the cargo type. + switch (appliesTo) { + case 'CONTAINER': + return ['PER_CONTAINER', 'PER_WAGON']; + case 'BULK': + return ['PER_TON', 'PER_WAGON']; + case 'INTERCITY': + return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM']; + case 'FIRST_MILE': + case 'LAST_MILE': + return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT']; + default: + return ['FLAT']; + } +} + +/** The default (first / most natural) unit for a rate shape. */ +export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit { + return allowedRateUnits(input)[0]; +} + +/** True when `unit` is a valid weighting basis for the given rate shape. */ +export function isRateUnitAllowed(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; + unit: RateUnit; +}): boolean { + return allowedRateUnits(input).includes(input.unit); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index b57b48cd8..50f8b3b99 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -81,7 +81,6 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) -@Index(['effectiveFrom']) @Index(['containerTypeId']) @Index(['trigger']) export class Rate extends BaseEntity { @@ -131,10 +130,4 @@ export class Rate extends BaseEntity { @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) approvedAt?: Date | null; - - @Column({ name: 'effective_from', type: 'date' }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index 39557eec9..b6b87b285 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity'; @Entity({ schema: 'freight', name: 'weight_limit_rules' }) @Index(['containerTypeId']) @Index(['tradeDirection']) -@Index(['effectiveFrom']) export class WeightLimitRule extends BaseEntity { @Column({ name: 'container_type_id', type: 'uuid' }) containerTypeId!: string; @@ -19,10 +18,4 @@ export class WeightLimitRule extends BaseEntity { @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) maxVgmTons!: number; - - @Column({ name: 'effective_from', type: 'date', nullable: true }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 52b991155..96db214c4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -4,6 +4,13 @@ import { Rate } from '../entities/rate.entity'; export interface IRatesRepository { findById(id: string): Promise; findLiveRates(): Promise; + findByPattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts index cedbd1eee..3c175df4e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise; + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 0d49a0bf3..a7260f7f1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -16,15 +16,50 @@ export class RatesRepository implements IRatesRepository { } findLiveRates(): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rate') .where('rate.status = :status', { status: 'LIVE' }) - .andWhere('rate.effective_from <= :now', { now }) - .andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now }) .getMany(); } + /** + * Find a non-superseded rate matching an identity pattern — the same tuple the + * `UQ_rates_pattern` unique index enforces. Used to reject duplicates before + * insert so the admin gets a friendly error instead of a raw constraint fault. + * NULL scope columns are matched with IS NULL, mirroring the COALESCE index. + */ + findByPattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise { + const qb = this.repo + .createQueryBuilder('rate') + .where('rate.rate_type = :rateType', { rateType: pattern.rateType }) + .andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }) + .andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' }); + + if (pattern.containerTypeId) { + qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); + } else { + qb.andWhere('rate.container_type_id IS NULL'); + } + if (pattern.cargoTypeId) { + qb.andWhere('rate.cargo_type_id = :cargoTypeId', { cargoTypeId: pattern.cargoTypeId }); + } else { + qb.andWhere('rate.cargo_type_id IS NULL'); + } + if (pattern.tradeDirection) { + qb.andWhere('rate.trade_direction = :tradeDirection', { tradeDirection: pattern.tradeDirection }); + } else { + qb.andWhere('rate.trade_direction IS NULL'); + } + + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts index 0d151c561..87d2febba 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rule') .innerJoinAndSelect('rule.containerType', 'ct') @@ -31,11 +30,27 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { dir: tradeDirection, both: 'BOTH', }) - .andWhere('rule.effective_from <= :now', { now }) - .andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now }) .getMany(); } + /** + * Find a rule matching the (containerType, tradeDirection) identity — the + * tuple enforced by `UQ_weight_limit_rules_pattern`. Used to reject duplicates + * before insert. Optionally excludes a row by id so updates don't self-collide. + */ + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise { + const qb = this.repo + .createQueryBuilder('rule') + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) + .andWhere('rule.trade_direction = :tradeDirection', { tradeDirection }); + if (excludeId) qb.andWhere('rule.id <> :excludeId', { excludeId }); + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index c3ab6bab0..0027e18c1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -1,8 +1,15 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateRateDto } from '../dto/create-rate.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; +import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; @Injectable() @@ -27,7 +34,7 @@ export class RatesService { const [data, total] = await this.repository.findAndCount({ where, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -46,6 +53,50 @@ export class RatesService { return entity; } + /** + * Normalise + validate the weighting unit for a rate shape. Overweight is + * always billed per excess ton, so its unit is forced to PER_TON regardless + * of what the client sent. Every other shape must pick a unit the pricing + * engine can actually apply (see `allowedRateUnits`). + */ + private resolveRateUnit( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + requestedUnit: Rate['rateUnit'], + ): Rate['rateUnit'] { + // Overweight is per-ton, full stop. + if (trigger === 'OVERWEIGHT') return 'PER_TON'; + + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + ); + } + return requestedUnit; + } + + /** + * Reject a second rate with the same identity pattern (rateType + scope). With + * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would + * make pricing ambiguous — so we allow exactly one per pattern. + */ + private async assertNoDuplicatePattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId: string | null; + cargoTypeId: string | null; + tradeDirection: string | null; + ignoreId?: string; + }): Promise { + const existing = await this.repository.findByPattern(pattern); + if (existing && existing.id !== pattern.ignoreId) { + throw new ConflictException( + 'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.', + ); + } + } + /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; @@ -57,25 +108,28 @@ export class RatesService { const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + const rateType = deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }); + const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + + await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection }); + return this.repository.create({ appliesTo, trigger, - rateType: deriveRateType({ - appliesTo, - trigger, - tradeDirection, - isBulk: Boolean(cargoTypeId), - }), + rateType, containerTypeId, cargoTypeId, tradeDirection, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, - rateUnit: dto.rateUnit as Rate['rateUnit'], + rateUnit, status: 'DRAFT', proposedByStaffId, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined, }); } @@ -110,22 +164,35 @@ export class RatesService { ? dto.tradeDirection : existing.tradeDirection; - updates.containerTypeId = containerTypeId; - updates.cargoTypeId = cargoTypeId; - updates.tradeDirection = tradeDirection; + updates.containerTypeId = containerTypeId ?? null; + updates.cargoTypeId = cargoTypeId ?? null; + updates.tradeDirection = tradeDirection ?? null; // Keep the derived rateType in sync with whatever changed. - updates.rateType = deriveRateType({ + const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, isBulk: Boolean(cargoTypeId), }); + updates.rateType = rateType; + + // Re-validate the unit against the (possibly changed) shape; overweight is + // forced to PER_TON. + const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; + updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); + + // Guard the pattern uniqueness for the new identity, ignoring this row. + await this.assertNoDuplicatePattern({ + rateType, + rateUnit: updates.rateUnit, + containerTypeId: updates.containerTypeId, + cargoTypeId: updates.cargoTypeId, + tradeDirection: updates.tradeDirection, + ignoreId: id, + }); updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; - if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo); const updated = await this.repository.update(id, updates); if (!updated) throw new NotFoundException(`Rate ${id} not found`); return updated; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index d171f55aa..bbd042296 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; @@ -30,7 +30,7 @@ export class WeightLimitRulesService { const [data, total] = await this.repository.findAndCount({ where, relations: { containerType: true }, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -44,26 +44,51 @@ export class WeightLimitRulesService { return entity; } + /** + * Reject a second rule for the same container + direction. One VGM limit per + * (container, direction) — otherwise the booking engine can't tell which + * applies. + */ + private async assertNoDuplicate( + containerTypeId: string, + tradeDirection: string, + ignoreId?: string, + ): Promise { + const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId); + if (existing) { + throw new ConflictException( + 'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.', + ); + } + } + /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { + await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); return this.repository.create({ containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, maxVgmTons: dto.maxVgmTons, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null, }); } /** Update an existing weight limit rule. */ async update(id: string, dto: UpdateWeightLimitRuleDto): Promise { - await this.findById(id); + const existing = await this.findById(id); const patch: Partial = {}; if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; - if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo); + + // Re-check uniqueness when the identity (container/direction) changes. + if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) { + await this.assertNoDuplicate( + patch.containerTypeId ?? existing.containerTypeId, + patch.tradeDirection ?? existing.tradeDirection, + id, + ); + } + const updated = await this.repository.update(id, patch); if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`); return updated; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index e765e5694..764589b73 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -3,9 +3,9 @@ import { listBatchWindowsForDate, listBatchWindowsForBookings, BATCH_WINDOW_START_HOURS, - boardWindowForTimestamp, - listBoardWindowsForRange, + listConfigBookingWindows, groupBookingsIntoBoardWindows, + type BoardWindowConfig, } from './batch-window.util'; describe('batch-window.util', () => { @@ -54,83 +54,87 @@ describe('batch-window.util', () => { }); }); -describe('batch-window board windows (midnight-based 3h slots)', () => { - it('maps 04:00 EAT to the 03:00–06:00 slot', () => { - // 01:00 UTC = 04:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z')); - expect(w.label).toContain('03:00'); - expect(w.label).toContain('06:00'); - expect(w.date).toBe('2026-06-11'); - expect(w.dateLabel).toContain('11 Jun'); - }); +describe('batch-window board windows (config-driven booking cycles)', () => { + // Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later. + const cfg: BoardWindowConfig = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowDurationHours: 3, + reopenDelayMinutes: 90, + exportBookingLeadHours: 24, + }; - it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => { - // 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z')); - expect(w.label).toContain('00:00'); - expect(w.label).toContain('03:00'); - expect(w.date).toBe('2026-06-11'); - }); - - it('maps 23:00 EAT to the final 21:00–24:00 slot', () => { - // 20:00 UTC = 23:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z')); - expect(w.label).toContain('21:00'); - expect(w.label).toContain('24:00'); - expect(w.date).toBe('2026-06-11'); - }); - - it('lists a continuous range open→departure clamped at both ends', () => { - // open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC) - const open = new Date('2026-06-05T05:00:00.000Z'); + it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => { + // departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC) const departure = new Date('2026-06-08T11:00:00.000Z'); - const windows = listBoardWindowsForRange(open, departure); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); - // Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5 - expect(windows).toHaveLength(6 + 8 + 8 + 5); expect(windows[0].date).toBe('2026-06-05'); - expect(windows[0].label).toContain('06:00'); - expect(windows[0].label).toContain('09:00'); - const last = windows[windows.length - 1]; - expect(last.date).toBe('2026-06-08'); - expect(last.label).toContain('12:00'); - expect(last.label).toContain('15:00'); - // chronological + unique keys - const keys = windows.map((w) => w.key); - expect(new Set(keys).size).toBe(keys.length); + expect(windows[0].label).toContain('08:00'); + expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z'); + // end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC) + expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z'); }); - it('handles a same-day open→departure range', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot) - const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot) - const windows = listBoardWindowsForRange(open, departure); - // 06,09,12 = 3 slots - expect(windows).toHaveLength(3); + it('import: reopens reopenDelayMinutes after close, same booking day', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); + // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT + expect(windows.length).toBeGreaterThanOrEqual(2); + expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT + // all cycles stay on the same EAT booking day expect(windows.every((w) => w.date === '2026-06-05')).toBe(true); }); - it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); - const departure = new Date('2026-06-06T11:00:00.000Z'); + it('export: single FCFS window exportBookingLeadHours before departure', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('EXPORT', departure, cfg); + expect(windows).toHaveLength(1); + // 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun + expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z'); + expect(windows[0].end.toISOString()).toBe(departure.toISOString()); + }); + + it('buckets bookings into config cycles and keeps empty + pending windows', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); const items = [ - { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th + { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1 { id: 'b', ts: null }, // pending ]; const map = groupBookingsIntoBoardWindows( items, (i) => i.ts, - open, + 'IMPORT', departure, + cfg, 'pending-contract', ); const pending = map.get('pending-contract'); expect(pending?.items.map((i) => i.id)).toEqual(['b']); const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a')); expect(withA?.window?.date).toBe('2026-06-05'); - // empty slots are retained for the UI + // empty cycles are retained for the UI const emptyCount = [...map.values()].filter( (b) => b.window && b.items.length === 0, ).length; expect(emptyCount).toBeGreaterThan(0); }); + + it('attaches a booking made before the window opened to the first cycle', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }]; + const map = groupBookingsIntoBoardWindows( + items, + (i) => i.ts, + 'IMPORT', + departure, + cfg, + 'pending-contract', + ); + const withEarly = [...map.values()].find((b) => + b.items.some((i) => i.id === 'early'), + ); + expect(withEarly?.window?.date).toBe('2026-06-05'); + expect(withEarly?.window?.label).toContain('08:00'); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 650da3adc..6d295c8fd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -230,14 +230,13 @@ export function listBatchWindowsForBookings( } // --------------------------------------------------------------------------- -// Board-display windows: full-day, midnight-based 3h slots over a date range. -// These are used ONLY for the batch-board UI grouping (not persisted, and -// independent of the cron intake hours above). +// Board-display windows: the REAL booking-window cycles derived from the +// train_scheduling_global_rules config (window open hour, lead days, duration, +// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle +// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after +// reopenDelayMinutes until departure). Export shows the single FCFS lead window. // --------------------------------------------------------------------------- -/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */ -export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const; - /** A board window carries an EAT calendar date in addition to the slot times. */ export interface BoardWindow extends BatchWindow { /** EAT calendar day as ISO `YYYY-MM-DD`. */ @@ -246,6 +245,15 @@ export interface BoardWindow extends BatchWindow { dateLabel: string; } +/** Config fields the board needs to reconstruct booking-window cycles. */ +export interface BoardWindowConfig { + importWindowLeadDays: number; + windowOpenHour: number; + windowDurationHours: number; + reopenDelayMinutes: number; + exportBookingLeadHours: number; +} + const dayLabelFmt = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: '2-digit', @@ -257,119 +265,124 @@ function pad2(n: number): string { return String(n).padStart(2, '0'); } -/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */ -function boardWindowFromEatStart( - year: number, - month: number, - day: number, - startHour: number, -): BoardWindow { - const start = eatToUtc(year, month, day, startHour); - const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over) - const end = eatToUtc(year, month, day, endHour); - const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`; +/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */ +function boardWindowFromInterval(start: Date, end: Date): BoardWindow { + const { year, month, day } = eatParts(start); return { key: start.toISOString(), start, end, - label: formatWindowLabel(start, end, endLabel), + label: formatWindowLabel(start, end), date: `${year}-${pad2(month)}-${pad2(day)}`, dateLabel: dayLabelFmt.format(start), }; } -/** Which midnight-based 3h EAT slot a timestamp falls in. */ -export function boardWindowForTimestamp(date: Date): BoardWindow { - const { year, month, day, hour } = eatParts(date); - let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0; - for (const h of BOARD_WINDOW_HOURS) { - if (hour >= h) startHour = h; - } - return boardWindowFromEatStart(year, month, day, startHour); -} - /** - * Continuous list of board windows from `openDate` to `departureDate` (inclusive), - * clamped to the slot containing `openDate` on the first day and the slot - * containing `departureDate` on the last day. Returned in chronological order. + * The real booking-window cycles for a schedule, straight from config. + * + * IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays` + * for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes` + * after each close, on the same booking day, until departure. This mirrors + * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the + * exact windows the engine runs. + * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure. */ -export function listBoardWindowsForRange( - openDate: Date, - departureDate: Date, +export function listConfigBookingWindows( + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, ): BoardWindow[] { - const startWin = boardWindowForTimestamp(openDate); - const endWin = boardWindowForTimestamp(departureDate); - // Guard against an inverted range (departure before open). - if (endWin.start.getTime() < startWin.start.getTime()) { - return [startWin]; + if (direction === 'EXPORT') { + const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000); + return [boardWindowFromInterval(start, departure)]; } const windows: BoardWindow[] = []; - const seen = new Set(); - // Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to - // avoid any boundary ambiguity, then filter to [startWin.start, endWin.start]. - let cursor = new Date(eatToUtc( - Number(startWin.date.slice(0, 4)), - Number(startWin.date.slice(5, 7)), - Number(startWin.date.slice(8, 10)), - 12, - )); - const lastDayMs = eatToUtc( - Number(endWin.date.slice(0, 4)), - Number(endWin.date.slice(5, 7)), - Number(endWin.date.slice(8, 10)), - 12, - ).getTime(); + const durationMs = cfg.windowDurationHours * 3_600_000; + const reopenMs = cfg.reopenDelayMinutes * 60_000; + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); - while (cursor.getTime() <= lastDayMs) { - const { year, month, day } = eatParts(cursor); - for (const h of BOARD_WINDOW_HOURS) { - const w = boardWindowFromEatStart(year, month, day, h); - if ( - w.start.getTime() >= startWin.start.getTime() && - w.start.getTime() <= endWin.start.getTime() && - !seen.has(w.key) - ) { - seen.add(w.key); - windows.push(w); - } + let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); + // Reopen stays on the same EAT booking day and before departure; cap at 12 cycles. + for (let cycle = 0; cycle < 12; cycle += 1) { + if (opensAt.getTime() >= departure.getTime()) break; + let closesAt = new Date(opensAt.getTime() + durationMs); + if (closesAt.getTime() > departure.getTime()) closesAt = departure; + windows.push(boardWindowFromInterval(opensAt, closesAt)); + + const nextOpensAt = new Date(closesAt.getTime() + reopenMs); + if ( + nextOpensAt.getTime() >= departure.getTime() || + eatDay(nextOpensAt) !== eatDay(opensAt) + ) { + break; } - cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); + opensAt = nextOpensAt; } - windows.sort(compareBatchWindows); + // Degenerate config (no window before departure) — surface a single window + // clamped to departure so the board still renders something meaningful. + if (windows.length === 0) { + windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure)); + } return windows; } +/** Which config booking-window a timestamp falls in; null if before/after all of them. */ +function configWindowForTimestamp( + windows: BoardWindow[], + date: Date, +): BoardWindow | null { + const ms = date.getTime(); + for (const w of windows) { + if (ms >= w.start.getTime() && ms < w.end.getTime()) return w; + } + return null; +} + /** - * Group items into board windows spanning [openDate, departureDate]. Empty - * windows are kept so the UI shows every slot. Items whose timestamp falls - * outside the range still get their own window (nothing hidden). Items without - * a timestamp go to `pendingKey`. + * Group items into the real config booking-window cycles for a schedule. Empty + * windows are kept so the UI shows every cycle. Items whose timestamp falls + * outside every window (e.g. a booking created before the window opened) are + * attached to the nearest window by start time so nothing is hidden. Items + * without a timestamp go to `pendingKey`. */ export function groupBookingsIntoBoardWindows( items: T[], getTimestamp: (item: T) => Date | null | undefined, - openDate: Date, - departureDate: Date, + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, pendingKey = 'pending-contract', ): Map { + const windows = listConfigBookingWindows(direction, departure, cfg); const map = new Map(); - - for (const w of listBoardWindowsForRange(openDate, departureDate)) { + for (const w of windows) { map.set(w.key, { window: w, items: [] }); } map.set(pendingKey, { window: null, items: [] }); + const firstWindow = windows[0] ?? null; + const lastWindow = windows[windows.length - 1] ?? null; + for (const item of items) { const ts = getTimestamp(item); if (!ts) { map.get(pendingKey)!.items.push(item); continue; } - const w = boardWindowForTimestamp(ts); - if (!map.has(w.key)) { - map.set(w.key, { window: w, items: [] }); + let w = configWindowForTimestamp(windows, ts); + if (!w) { + // Booked before the window opened → first cycle; after it closed → last cycle. + w = + firstWindow && ts.getTime() < firstWindow.start.getTime() + ? firstWindow + : lastWindow; + } + if (!w) { + map.get(pendingKey)!.items.push(item); + continue; } map.get(w.key)!.items.push(item); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 2712c6b48..f112d16d7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -269,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => { }), ); }); + + it('reserves both partners of a consolidated pair together on one train', async () => { + // Two 20ft bookings, 1 container each — a shared wagon. Both in the pool. + const consol = (id: string, partnerId: string, priority: number): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: partnerId, + bookingContainers: [{ quantity: 1 }], + }) as unknown as Booking; + + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + consol('a', 'b', 30), + consol('b', 'a', 20), + ]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Both reserved on the same (first) train; neither reported unplaced. + const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); + expect(reservedIds.sort()).toEqual(['a', 'b']); + expect(notifier.unplaced).not.toHaveBeenCalled(); + }); + + it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => { + const lonely = { + id: 'a', + reference: 'a', + isGovernment: false, + priorityScore: 30, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: 'missing-partner', + bookingContainers: [{ quantity: 1 }], + } as unknown as Booking; + + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Never reserved — waits for its partner in a later cycle. + expect(notifier.payNow).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 722744c2a..efa6b3259 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -9,7 +9,7 @@ import { } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { SchedulerRegistry } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +import { DataSource, In } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; @@ -40,6 +40,7 @@ import { import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; +import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -89,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking { selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; allocationIssue: string | null; + /** Set when this booking shares a wagon with a consolidation partner. */ + consolidationPartnerId: string | null; + consolidationPartnerRef: string | null; } export interface BatchWindowGroup { @@ -433,7 +437,7 @@ export class BookingBatchService implements OnModuleInit { * fits the booking. Throws ConflictException when every train is full — the * staff accept fails and no more export bookings are taken. */ - async pickExportSchedule(booking: Booking): Promise { + async pickExportSchedule(booking: Booking, need?: Capacity): Promise { if (!booking.scheduledDate) { throw new BadRequestException('Booking has no scheduled date'); } @@ -471,7 +475,7 @@ export class BookingBatchService implements OnModuleInit { const rules = await this.loadGlobalRules(); const wagonLengths = await this.loadWagonLengths(); - const need = this.needFor(booking, wagonLengths); + const required = need ?? this.needFor(booking, wagonLengths); for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, @@ -480,18 +484,47 @@ export class BookingBatchService implements OnModuleInit { if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive, rules); const budget = await this.remainingCapacity(schedule, limits, wagonLengths); - if (this.fits(need, budget)) return schedule.id; + if (this.fits(required, budget)) return schedule.id; } throw new ConflictException('Train is full — no export capacity left for this day'); } /** - * Reserve an accepted export booking on its picked train and open the pay - * window immediately (payment notification goes out on reserve). Marks the - * train FULL when this reservation exhausts the wagon budget. + * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. + * A consolidated booking reserves as a pair only once BOTH partners are ready + * (FULLY_EXECUTED): the second partner's accept triggers the pair reservation + * against the combined shared-wagon need; the first partner's accept just waits. + * Throws ConflictException (before this booking is persisted-ready) when there is + * no export capacity for the day, so staff accept fails. */ - async reserveExportBooking(booking: Booking, scheduleId: string): Promise { - await this.reserve(booking, scheduleId); + async acceptExportBooking(booking: Booking): Promise { + const partnerId = booking.consolidationPartnerId ?? null; + if (!partnerId) { + const scheduleId = await this.pickExportSchedule(booking); + await this.reserveOnExport([booking], scheduleId); + return; + } + + const partner = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } }); + // Partner not yet accepted → this booking is now FULLY_EXECUTED and simply + // waits; the partner's later accept will reserve the pair. + if (!partner || partner.status !== 'FULLY_EXECUTED') { + return; + } + const wagonLengths = await this.loadWagonLengths(); + const need = this.combinedNeed(booking, partner, wagonLengths); + const scheduleId = await this.pickExportSchedule(booking, need); + await this.reserveOnExport([booking, partner], scheduleId); + } + + /** Reserve one or two (consolidated) export bookings on a train and open pay windows. */ + private async reserveOnExport( + bookings: Booking[], + scheduleId: string, + ): Promise { + for (const b of bookings) await this.reserve(b, scheduleId); this.armSettle(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); @@ -557,6 +590,9 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; + // Batch board is IMPORT-only: export is FCFS with no batch/priority calc, + // and domestic/legacy schedules run the legacy fill, not the window batch. + if (s.direction !== "IMPORT") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -597,6 +633,12 @@ export class BookingBatchService implements OnModuleInit { if (s.status === "ARRIVED" || s.status === "CANCELLED") { throw new BadRequestException("Schedule is no longer active"); } + // Batch board is IMPORT-only (export is FCFS, no batch/priority calc). + if (s.direction !== "IMPORT") { + throw new BadRequestException( + "The batch board only covers import schedules", + ); + } const wagonLengths = await this.loadWagonLengths(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); @@ -622,6 +664,27 @@ export class BookingBatchService implements OnModuleInit { allocationPreview.issues.map((i) => [i.bookingId, i]), ); + // Resolve consolidation-partner references for the shared-wagon badge. Most + // partners are on this same schedule; look up any that aren't in one query. + const refById = new Map( + bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]), + ); + const missingPartnerIds = [ + ...new Set( + bookings + .map((b) => b.consolidationPartnerId) + .filter((id): id is string => Boolean(id) && !refById.has(id!)), + ), + ]; + if (missingPartnerIds.length) { + const partners = await this.dataSource + .getRepository(Booking) + .find({ where: { id: In(missingPartnerIds) } }); + for (const p of partners) { + refById.set(p.id, p.reference ?? p.id.slice(0, 8)); + } + } + const items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonLengths); const alloc = allocationByBooking.get(b.id); @@ -647,20 +710,27 @@ export class BookingBatchService implements OnModuleInit { : null, allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, + consolidationPartnerId: b.consolidationPartnerId ?? null, + consolidationPartnerRef: b.consolidationPartnerId + ? (refById.get(b.consolidationPartnerId) ?? null) + : null, }; }); const loco = s.trainSet?.locomotive ?? null; - // Display windows span the whole booking window: from when it opened - // (schedule creation) through the scheduled departure, in 3-hour EAT slots. - const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date(); + // Display windows are the REAL booking-window cycles from the global-rules + // config (import: opens at windowOpenHour EAT importWindowLeadDays before + // departure, lasts windowDurationHours, reopens per reopenDelayMinutes; + // export: single FCFS lead window) — not a fixed clock grid. + const windowCfg = await this.trainSchedulingService.getWindowConfig(); const departureDate = s.scheduledDepartureDate ?? new Date(); const windowBuckets = groupBookingsIntoBoardWindows( items, (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), - openDate, + s.direction ?? null, departureDate, + windowCfg, ); const emptyCounts = () => ({ @@ -903,13 +973,19 @@ export class BookingBatchService implements OnModuleInit { } const pool = await this.bookingsRepository.findBatchPool(scheduleId); + const units = this.groupConsolidatedPool(pool); let armed = false; - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); if (!this.fits(need, budget)) { - if (booking.isGovernment) { + if (isGov) { budget = await this.preemptForGovernment( scheduleId, need, @@ -918,14 +994,16 @@ export class BookingBatchService implements OnModuleInit { ); if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt } else { - continue; // skip a booking that exceeds weight/length/wagons, try the next + continue; // skip a unit that exceeds weight/length/wagons, try the next } } - if (booking.isGovernment) { + if (isGov) { await this.allocate(scheduleId, booking, "gov"); + if (partner) await this.allocate(scheduleId, partner, "gov"); } else { await this.reserve(booking, scheduleId); + if (partner) await this.reserve(partner, scheduleId); armed = true; } budget = this.subtract(budget, need); @@ -1013,15 +1091,23 @@ export class BookingBatchService implements OnModuleInit { destinationYardId, day, ); + // Consolidated partners collapse into one atomic unit (both-or-neither); a + // consolidated booking whose partner isn't ready this cycle is skipped. + const units = this.groupConsolidatedPool(pool); - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); - // First train (earliest departure) that fits this booking as-is. + // First train (earliest departure) that fits this unit as-is. let target = trains.find((t) => this.fits(need, t.budget)); - if (!target && booking.isGovernment) { - // Government booking fits nowhere on its own — try to preempt commercial + if (!target && isGov) { + // Government fits nowhere on its own — try to preempt commercial // on each train (earliest first) until one frees enough room. for (const t of trains) { t.budget = await this.preemptForGovernment( @@ -1038,41 +1124,45 @@ export class BookingBatchService implements OnModuleInit { } if (!target) { - // Fits no train whole. Import GENERAL-contract commercial bookings get a - // partial-capacity offer on the train with the most free wagons: pay = - // accept the split (remainder returns to the contract cap), no pay = - // booking stays whole and expires for this train. - const partialTarget = [...trains] - .filter((t) => t.budget.wagons >= 1) - .sort((a, b) => b.budget.wagons - a.budget.wagons)[0]; - if ( - partialTarget && - !booking.isGovernment && - booking.tradeDirection === "IMPORT" && - booking.contractKind === "GENERAL" && - this.splitService - ) { - const offered = await this.tryPartialOffer( - booking, - partialTarget.id, - partialTarget.budget, - need, - ); - if (offered) { - partialTarget.budget = this.subtract(partialTarget.budget, offered); - partialTarget.armed = true; - continue; + // A consolidated pair is placed whole or not at all — never split. + if (!isPair) { + // Fits no train whole. Import GENERAL-contract commercial bookings get a + // partial-capacity offer on the train with the most free wagons. + const partialTarget = [...trains] + .filter((t) => t.budget.wagons >= 1) + .sort((a, b) => b.budget.wagons - a.budget.wagons)[0]; + if ( + partialTarget && + !booking.isGovernment && + booking.tradeDirection === "IMPORT" && + booking.contractKind === "GENERAL" && + this.splitService + ) { + const offered = await this.tryPartialOffer( + booking, + partialTarget.id, + partialTarget.budget, + need, + ); + if (offered) { + partialTarget.budget = this.subtract(partialTarget.budget, offered); + partialTarget.armed = true; + continue; + } } } // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); + if (partner) this.notifier.unplaced(partner, day); continue; } - if (booking.isGovernment) { + if (isGov) { await this.allocate(target.id, booking, "gov"); + if (partner) await this.allocate(target.id, partner, "gov"); } else { await this.reserve(booking, target.id); + if (partner) await this.reserve(partner, target.id); target.armed = true; } target.budget = this.subtract(target.budget, need); @@ -1099,6 +1189,8 @@ export class BookingBatchService implements OnModuleInit { need: Capacity, ): Promise { if (!this.splitService) return null; + // A consolidated booking is already half of a shared wagon — never split it. + if (booking.consolidationPartnerId) return null; if (await this.splitService.findOpenOffer(booking.id)) return null; const wagonLengths = await this.loadWagonLengths(); @@ -1143,29 +1235,69 @@ export class BookingBatchService implements OnModuleInit { return capacity > 0 ? capacity : 60; } - /** Durable settle: allocate paid / expire overdue reservations, then top up. */ - async settleDueReservations(scheduleId: string): Promise { + /** + * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides + * how to treat a reservation with no deadline (durable path: leave it; timeout + * path: expire it). Consolidated pairs settle atomically: both allocate only + * when both paid; if either partner expires, both expire (a half-paid shared + * wagon must not ship). Returns whether anything changed. + */ + private async settleReserved( + scheduleId: string, + expireUnpaidUnknownDeadline: boolean, + ): Promise { const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); + const byId = new Map(reserved.map((b) => [b.id, b])); + const done = new Set(); let anySettled = false; - for (const booking of reserved) { - const paid = - booking.paymentStatus === "PAID" || booking.status === "PAID"; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : false; + const isPaid = (b: Booking) => + b.paymentStatus === "PAID" || b.status === "PAID"; + const isExpired = (b: Booking) => + b.paymentDeadline + ? b.paymentDeadline.getTime() <= now + : expireUnpaidUnknownDeadline; - if (paid) { + for (const booking of reserved) { + if (done.has(booking.id)) continue; + const partner = booking.consolidationPartnerId + ? (byId.get(booking.consolidationPartnerId) ?? null) + : null; + + if (partner) { + done.add(booking.id); + done.add(partner.id); + // Both-or-neither: allocate the shared wagon only when both partners paid; + // if either lapsed, expire both so no half-paid wagon rides. + if (isPaid(booking) && isPaid(partner)) { + await this.allocate(scheduleId, booking, "paid"); + await this.allocate(scheduleId, partner, "paid"); + anySettled = true; + } else if (isExpired(booking) || isExpired(partner)) { + await this.expire(booking); + await this.expire(partner); + anySettled = true; + } + continue; + } + + done.add(booking.id); + if (isPaid(booking)) { await this.allocate(scheduleId, booking, "paid"); anySettled = true; - } else if (expired) { + } else if (isExpired(booking)) { await this.expire(booking); anySettled = true; } } + return anySettled; + } + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ + async settleDueReservations(scheduleId: string): Promise { + const anySettled = await this.settleReserved(scheduleId, false); if (anySettled) await this.fillSchedule(scheduleId); } @@ -1174,25 +1306,7 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - const reserved = - await this.bookingsRepository.findReservedForSchedule(scheduleId); - const now = Date.now(); - - for (const booking of reserved) { - const paid = - booking.paymentStatus === "PAID" || booking.status === "PAID"; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : true; - - if (paid) { - await this.allocate(scheduleId, booking, "paid"); - } else if (expired) { - await this.expire(booking); - } - // else: still within window (rare at settle) → leave for the re-armed timeout - } - + await this.settleReserved(scheduleId, true); await this.fillSchedule(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -1452,6 +1566,74 @@ export class BookingBatchService implements OnModuleInit { // ---- capacity helpers ----------------------------------------------------- + /** + * Collapse consolidated partners into single pool entries so the fill treats a + * shared-wagon pair as one atomic unit (both-or-neither). For each pool entry: + * - no `consolidationPartnerId` → passes through as a lone booking. + * - consolidated + partner also in this pool → emitted ONCE (at the position of + * whichever partner ranks first) as a pair; the partner is not emitted again. + * - consolidated + partner NOT in this pool → dropped (can't ship half a wagon; + * it waits for the partner to become ready in a later cycle). + * The pool is already priority-ordered, so emitting the pair at the first-seen + * partner's slot ranks it by the stronger (max-priority) partner automatically. + */ + private groupConsolidatedPool( + pool: Booking[], + ): Array<{ primary: Booking; partner: Booking | null }> { + const byId = new Map(pool.map((b) => [b.id, b])); + const emitted = new Set(); + const units: Array<{ primary: Booking; partner: Booking | null }> = []; + for (const booking of pool) { + if (emitted.has(booking.id)) continue; + const partnerId = booking.consolidationPartnerId ?? null; + if (!partnerId) { + emitted.add(booking.id); + units.push({ primary: booking, partner: null }); + continue; + } + const partner = byId.get(partnerId) ?? null; + if (!partner) { + // Both-or-neither: partner not ready in this pool → skip the pair entirely. + emitted.add(booking.id); + continue; + } + emitted.add(booking.id); + emitted.add(partner.id); + units.push({ primary: booking, partner }); + } + return units; + } + + /** + * Combined capacity need of a consolidated pair sharing wagons. The whole point of + * consolidation is that the two partial 20ft counts pack onto the SAME wagons, so + * the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two + * independently-rounded-up needs (that is the capacity consolidation saves). + */ + private combinedNeed( + primary: Booking, + partner: Booking, + wagonLengths: WagonLengths, + ): Capacity { + const containers = (b: Booking): number => + (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); + const totalContainers = containers(primary) + containers(partner); + const sharedWagons = + totalContainers > 0 + ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) + : this.wagonsFor(primary) + this.wagonsFor(partner); + const weightTons = + Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + return { + wagons: sharedWagons, + weightTons, + lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + } + private wagonsFor(booking: Booking): number { if (booking.wagonsRequired && booking.wagonsRequired > 0) { return Math.ceil(booking.wagonsRequired); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index cdcd8aca8..2c678ea44 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3257,6 +3257,53 @@ export class TrainSchedulingService { return days.includes(day); } + /** + * Enforce the config-driven booking window at booking-create time. + * + * A booking is only allowed when the route has an OPEN departure the customer + * can join for the requested day — which, because the window engine keeps + * `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means: + * - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT, + * `importWindowLeadDays` before departure, for `windowDurationHours`). + * - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS). + * + * `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so + * both gates are satisfied by checking that route for open departures. When a + * specific day is requested, require an open departure on that EAT day; when no + * day is given, require at least one open departure on the route at all. + * Throws `BadRequestException` when the window is closed. No-ops when the route + * yards are unknown (nothing to gate against). + */ + async assertBookingWindowOpen(input: { + originYardId?: string | null; + destinationYardId?: string | null; + scheduledDate?: Date | string | null; + direction?: string | null; + }): Promise { + const { originYardId, destinationYardId } = input; + if (!originYardId || !destinationYardId) return; + + const { days } = await this.getAvailableDays(originYardId, destinationYardId); + if (days.length === 0) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'The export booking window for this route is not open yet' + : 'The import booking window for this route is closed right now', + ); + } + + if (input.scheduledDate) { + const day = eatDay(new Date(input.scheduledDate)); + if (!days.includes(day)) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'No departure is within the export booking window on the selected day' + : 'The import booking window is not open for the selected day', + ); + } + } + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 3c295c9d0..e6ea89fbe 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,6 +1,7 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, + POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; @@ -10,6 +11,13 @@ export type FreightSeedRole = { permissionKeys: string[]; }; +export type FreightSeedPosition = { + key: string; + name: { en: string }; + rank: number; + permissionKeys: string[]; +}; + const IAM_PERMISSION_KEYS = { activateEmployee: "can:activateEmployee", activateUser: "can:activateUser", @@ -282,3 +290,18 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ permissionKeys: [], }, ]; + +/** + * Operational positions (positions-as-roles). Seeded as Position + + * PositionPermission rows (NOT Role/RolePermission). Users get their access by + * being assigned to a Position via EmployeePosition. + */ +export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ + { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] }, + { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] }, + { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] }, + { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] }, + { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, + { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, + { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, +]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 8243ef267..cb379890a 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -3,14 +3,28 @@ import { Organization, OrganizationConfiguration, Permission, + Position, + PositionPermission, + PositionType, Role, RolePermission, + Unit, } from "@tria-plc/iamapi-common"; import { DataSource, EntityManager, In } from "typeorm"; import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; -import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; +import { + EDR_FREIGHT_POSITIONS, + EDR_FREIGHT_ROLES, + type FreightSeedPosition, + type FreightSeedRole, +} from "./edr-freight.seed"; + +const EDR_UNIT_KEY = "edr_freight_hq"; +const EDR_UNIT_NAME = { en: "EDR Freight HQ" }; +const EDR_POSITION_TYPE_KEY = "edr_freight_role"; +const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" }; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; @@ -40,6 +54,19 @@ export class EdrOrgSeeder { await this.ensureRoles(manager, EDR_FREIGHT_ROLES); await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); await this.ensureSuperAdminPermissions(manager); + + // Positions-as-roles: seed operational positions and grant their + // permissions via PositionPermission (not Role/RolePermission). + const unit = await this.ensureDefaultUnit(manager, organization.id); + const positionType = await this.ensureDefaultPositionType(manager, unit.id); + await this.ensurePositions( + manager, + organization.id, + unit.id, + positionType.id, + EDR_FREIGHT_POSITIONS, + ); + await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS); }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -205,4 +232,146 @@ export class EdrOrgSeeder { `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, ); } + + private async ensureDefaultUnit( + manager: EntityManager, + organizationId: string, + ): Promise<{ id: string }> { + const unitRepository = manager.getRepository(Unit); + + let unit = await unitRepository.findOne({ + where: { key: EDR_UNIT_KEY, organizationId }, + select: { id: true }, + }); + + if (!unit) { + const insertResult = await unitRepository.insert({ + key: EDR_UNIT_KEY, + name: EDR_UNIT_NAME, + organizationId, + }); + this.logger.log(`Seeded EDR unit '${EDR_UNIT_KEY}'`); + return { id: insertResult.identifiers[0]?.id as string }; + } + + this.logger.log(`Ensured EDR unit '${EDR_UNIT_KEY}'`); + return { id: unit.id }; + } + + private async ensureDefaultPositionType( + manager: EntityManager, + unitId: string, + ): Promise<{ id: string }> { + const positionTypeRepository = manager.getRepository(PositionType); + + // PositionType has no unique constraint on (key, unitId); find-then-insert. + let positionType = await positionTypeRepository.findOne({ + where: { key: EDR_POSITION_TYPE_KEY, unitId }, + select: { id: true }, + }); + + if (!positionType) { + const insertResult = await positionTypeRepository.insert({ + key: EDR_POSITION_TYPE_KEY, + name: EDR_POSITION_TYPE_NAME, + isSystem: true, + unitId, + }); + this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`); + return { id: insertResult.identifiers[0]?.id as string }; + } + + this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`); + return { id: positionType.id }; + } + + private async ensurePositions( + manager: EntityManager, + organizationId: string, + unitId: string, + positionTypeId: string, + seedPositions: FreightSeedPosition[], + ) { + await manager.getRepository(Position).upsert( + seedPositions.map(({ key, name, rank }) => ({ + key, + name, + rank, + organizationId, + unitId, + positionTypeId, + })), + { + conflictPaths: { key: true, unitId: true }, + }, + ); + + this.logger.log( + `Ensured ${seedPositions.length} EDR positions '${seedPositions + .map((position) => position.key) + .join("', '")}'`, + ); + } + + private async ensurePositionPermissions( + manager: EntityManager, + unitId: string, + seedPositions: FreightSeedPosition[], + ) { + const permissionKeys = [ + ...new Set(seedPositions.flatMap((position) => position.permissionKeys)), + ]; + + if (!permissionKeys.length) { + this.logger.log( + "No EDR position permissions configured; skipping position-permission links", + ); + return; + } + + const positions = await manager.getRepository(Position).find({ + where: { key: In(seedPositions.map((position) => position.key)), unitId }, + select: { id: true, key: true }, + }); + const seededPermissions = await manager.getRepository(Permission).find({ + where: { key: In(permissionKeys) }, + select: { id: true, key: true }, + }); + + const positionByKey = new Map( + positions.map((position) => [position.key, position]), + ); + const permissionByKey = new Map( + seededPermissions.map((permission) => [permission.key, permission]), + ); + + const positionPermissions = seedPositions.flatMap((position) => { + const seededPosition = positionByKey.get(position.key); + + if (!seededPosition) { + throw new Error(`missing_position:${position.key}`); + } + + return position.permissionKeys.map((permissionKey) => { + const seededPermission = permissionByKey.get(permissionKey); + + if (!seededPermission) { + throw new Error(`missing_permission:${permissionKey}`); + } + + return { + positionId: seededPosition.id as string, + permissionId: seededPermission.id, + }; + }); + }); + + await manager.getRepository(PositionPermission).upsert(positionPermissions, { + conflictPaths: { positionId: true, permissionId: true }, + }); + + this.logger.log( + `Ensured ${positionPermissions.length} EDR position-permission links`, + ); + } } 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 56ca98626..d70cb5fb4 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -109,10 +109,19 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO }, ); +/** + * Container-allocation permission for the previously-unguarded + * booking allocate-containers endpoint. + */ +export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'), +]; + export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...BOOKING_PERMISSIONS, ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, + ...GAP_CONTROLLER_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( @@ -171,6 +180,9 @@ export const FREIGHT_PERMS = { manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, }, + allocation: { + manage: 'edr_freight_app:allocation:manage', + }, } as const; const allRuleEngineViewKeys = () => @@ -286,6 +298,34 @@ export const ROLE_PERMISSION_PRESETS = { orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; +/** + * Position permission presets (positions-as-roles). Grants flow to users via + * Position → PositionPermission (NOT Role/RolePermission). Each reuses the + * matching ROLE_PERMISSION_PRESETS key-array as a building block and adds the + * gap-controller keys the position needs. Deduped via Set. + */ +const dedupe = (keys: string[]): string[] => [...new Set(keys)]; + +export const POSITION_PERMISSION_PRESETS = { + // Chief: senior operational role — intake/line-staff approval + director + // approval + scheduling/ops, plus container allocation. + chief: dedupe([ + ...ROLE_PERMISSION_PRESETS.lineStaff, + ...ROLE_PERMISSION_PRESETS.director, + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), + director: dedupe([...ROLE_PERMISSION_PRESETS.director]), + ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), + ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), + djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]), + marketer: dedupe([...ROLE_PERMISSION_PRESETS.marketing]), + operation: dedupe([ + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), +} as const; + export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ key: p.key, label: p.name.en, diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index b6d83c138..a8c16ef05 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -3,8 +3,11 @@ import { hashPassword } from '@tria-plc/api-common/utils/argon'; import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; import { Employee, + EmployeePosition, Organization, + Position, Role, + Unit, User, UserCredential, UserRole, @@ -13,13 +16,19 @@ import { DataSource } from 'typeorm'; const SEED_FLAG = 'SEED_FREIGHT_STAFF'; const EDR_ORG_KEY = 'edr_freight'; +const EDR_UNIT_KEY = 'edr_freight_hq'; +// roleKey is kept only for backwards compatibility with existing UserRole rows; +// access is granted via the assigned position (positionKey) + PositionPermission. const STAFF_USERS = [ - { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' }, - { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, - { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, - { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' }, - { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' }, + { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff', positionKey: 'operation' }, + { email: 'chief@edr.local', username: 'chief', roleKey: 'edr_org_manager', positionKey: 'chief' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director', positionKey: 'director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo', positionKey: 'ceo' }, + { email: 'marketer@edr.local', username: 'marketer', roleKey: 'edr_marketing', positionKey: 'marketer' }, + { email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' }, + { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' }, + { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' }, ] as const; @Injectable() @@ -47,11 +56,22 @@ export class FreightStaffUsersSeeder { throw new Error(`missing_organization:${EDR_ORG_KEY}`); } + const unit = await manager.getRepository(Unit).findOne({ + where: { key: EDR_UNIT_KEY, organizationId: organization.id }, + select: { id: true }, + }); + + if (!unit) { + throw new Error(`missing_unit:${EDR_UNIT_KEY}`); + } + const roleRepository = manager.getRepository(Role); const userRepository = manager.getRepository(User); const userCredentialRepository = manager.getRepository(UserCredential); const userRoleRepository = manager.getRepository(UserRole); const employeeRepository = manager.getRepository(Employee); + const positionRepository = manager.getRepository(Position); + const employeePositionRepository = manager.getRepository(EmployeePosition); const hashedPassword = await hashPassword(password); @@ -105,20 +125,50 @@ export class FreightStaffUsersSeeder { { conflictPaths: { userId: true, roleId: true } }, ); - const employeeExists = await employeeRepository.exists({ + let employee = await employeeRepository.findOne({ where: { userId: user.id, organizationId: organization.id, isCurrent: true, }, + select: { id: true }, }); - if (!employeeExists) { - await employeeRepository.insert({ - userId: user.id, - organizationId: organization.id, + if (!employee) { + employee = await employeeRepository.save( + employeeRepository.create({ + userId: user.id, + organizationId: organization.id, + unitId: unit.id, + isCurrent: true, + name: { en: staff.username }, + }), + ); + } + + // Grant access via the assigned position (positions-as-roles). + const position = await positionRepository.findOne({ + where: { key: staff.positionKey, unitId: unit.id }, + select: { id: true, key: true }, + }); + + if (!position) { + throw new Error(`missing_position:${staff.positionKey}`); + } + + const employeePositionExists = await employeePositionRepository.exists({ + where: { + employeeId: employee.id as string, + positionId: position.id as string, + }, + }); + + if (!employeePositionExists) { + await employeePositionRepository.insert({ + employeeId: employee.id as string, + positionId: position.id as string, + unitId: unit.id, isCurrent: true, - name: { en: staff.username }, }); } } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 02a05602e..14ccb3efb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -319,37 +319,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); - const base = new Date("2026-01-01"); - const rules = [ - { - containerTypeId: twenty.id, - tradeDirection: "IMPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: twenty.id, - tradeDirection: "EXPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "IMPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "EXPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, + { containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26 }, + { containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26 }, + { containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28 }, + { containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28 }, ]; for (const rule of rules) { @@ -361,10 +335,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { }); if (existing) { - await wlRepo.update(existing.id, { - maxVgmTons: rule.maxVgmTons, - effectiveFrom: rule.effectiveFrom, - }); + await wlRepo.update(existing.id, { maxVgmTons: rule.maxVgmTons }); } else { await wlRepo.insert(rule); } @@ -409,7 +380,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { ctByCode: Map, cargoByCode: Map, ): Promise { - const effectiveFrom = new Date("2026-01-01"); const now = new Date(); // Each rate is self-describing: `appliesTo` + `trigger` decide how the @@ -479,7 +449,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { proposedByStaffId: STAFF_USER_ID, approvedByCeoId: CEO_USER_ID, approvedAt: now, - effectiveFrom, })) .filter((d) => !existingBySignature.has(signature(d))); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 1558eb727..966a84132 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -12,6 +12,7 @@ import { PackageCheck, PackageOpen, Paperclip, + Receipt, Send, Settings, ShieldCheck, @@ -32,7 +33,11 @@ import { useParams, } from "react-router-dom"; -import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { + FreightDashboardLayout, + type SidebarItem, + type SidebarSection, +} from "@/components/layout"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; +import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; +import InvoicesPage from "./pages/invoices/InvoicesPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; @@ -61,7 +68,13 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import { + FREIGHT_PERMS, + hasPermission as hasFreightPermission, + isDjiboutiGl, + isEthiopianGl, + isSuperAdmin, +} from "./lib/permissions"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; @@ -144,6 +157,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, + { + label: "Invoices", + href: "/dashboard/invoices", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, ...demoItems, ], }, @@ -159,12 +178,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", @@ -435,12 +454,35 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ]; -/** Keep only items the user is permitted to see; drop now-empty sections. */ +/** Hrefs of the two document-clearance menu items (stable identifiers). */ +const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; +const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; + +const isEtClearanceItem = (item: SidebarItem): boolean => + item.href === ET_CLEARANCE_HREF; +const isDjClearanceItem = (item: SidebarItem): boolean => + item.href === DJ_CLEARANCE_HREF; +const isClearanceItem = (item: SidebarItem): boolean => + isEtClearanceItem(item) || isDjClearanceItem(item); + +/** + * Keep only items the user is permitted to see; drop now-empty sections. + * + * Position-scoped visibility (super_admin bypasses all of this): + * - Ethiopian GL → sees ONLY the ET document-clearance page. + * - Djibouti GL → sees ONLY the DJ clearance page. + * - Everyone else → sees everything they have permission for, EXCEPT the two + * clearance pages (those are GL-only). + */ const filterSidebarByPermission = ( sections: SidebarSection[], user: ReturnType["user"], ): SidebarSection[] => { - const itemAllowed = (item: SidebarItem): boolean => { + const superAdmin = isSuperAdmin(user); + const etGl = !superAdmin && isEthiopianGl(user); + const djGl = !superAdmin && isDjiboutiGl(user); + + const permissionAllowed = (item: SidebarItem): boolean => { if (!item.permission) return true; const keys = Array.isArray(item.permission) ? item.permission @@ -448,6 +490,19 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; + const itemAllowed = (item: SidebarItem): boolean => { + if (superAdmin) return true; + + // GL positions are locked to their single clearance page. + if (etGl) return isEtClearanceItem(item); + if (djGl) return isDjClearanceItem(item); + + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + + return permissionAllowed(item); + }; + return sections .map((section) => ({ ...section, @@ -469,6 +524,22 @@ const DashboardShell = () => { ); const displayName = user?.name?.en || user?.username || user?.email || "User"; + // GL positions are locked to their single clearance page: if they navigate + // (or deep-link) anywhere else, send them back to their clearance hub. + // Super admin is exempt. Allow the clearance path + its detail sub-routes. + const superAdmin = isSuperAdmin(user); + const glClearanceHome = !superAdmin + ? isEthiopianGl(user) + ? ET_CLEARANCE_HREF + : isDjiboutiGl(user) + ? DJ_CLEARANCE_HREF + : null + : null; + + if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) { + return ; + } + return ( { } /> } /> } /> - } /> + } + /> }> } /> } /> @@ -522,8 +596,27 @@ const App = () => { /> } /> } /> + + + + } + /> + + + + } + /> } /> - } /> + } + /> } @@ -536,7 +629,9 @@ const App = () => { + } @@ -570,7 +665,9 @@ const App = () => { + } @@ -578,7 +675,9 @@ const App = () => { + } @@ -586,7 +685,9 @@ const App = () => { + } @@ -618,12 +719,20 @@ const App = () => { } /> - } /> - } /> + } + /> + } + /> + } @@ -631,7 +740,9 @@ const App = () => { + } @@ -644,7 +755,9 @@ const App = () => { + } @@ -655,155 +768,174 @@ const App = () => { /> } /> } /> - } /> + } + /> } /> } /> } /> } /> } /> } /> - } /> - } /> + } + /> + } + /> } /> } /> - } /> - } /> + } + /> + } + /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> } + element={ + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + } /> { {/* Legacy embedded user management routes */} } /> } /> - } /> + } + /> {/* } /> */} - } /> + } + /> } /> { } + element={ + + } /> { } /> } /> - } /> - } /> + } + /> + } + /> { } + element={ + + } + /> + } /> - } /> } /> } /> @@ -1026,9 +1177,7 @@ const App = () => { /** Redirect removed milestones page to document clearance. */ function BookingMilestonesRedirect() { const { id } = useParams(); - return ( - - ); + return ; } /** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */ diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index e108d1b1f..31c7efc52 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -1,3 +1,4 @@ +import type { Freight } from "@edr/types"; import { Badge, Button, Group, Tooltip } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -88,7 +89,13 @@ export function ProfileChips({ }) { if (!profiles.length) { return ( - + No profiles ); @@ -118,7 +125,13 @@ export function ProfileChips({ ))} {extra > 0 ? ( - + +{extra} ) : null} @@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record = { CANCELLED: "red", }; -export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) { +export function BookingStatusBadge({ + status, +}: { + status: CustomerBookingStatus; +}) { return ( = { refunded: "grape", }; -export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) { +export function PaymentStatusBadge({ + status, +}: { + status: CustomerPaymentStatus; +}) { return ( = { + DRAFT: "gray", + ISSUED: "cyan", + PENDING: "yellow", + PARTIALLY_PAID: "orange", + PAID: "edr-green", + OVERDUE: "red", + CANCELLED: "gray", + REFUNDED: "grape", + EXPIRED: "red", +}; + +export function InvoiceStatusBadge({ + status, +}: { + status: Freight.InvoiceStatus; +}) { + return ( + + {humanize(status)} + + ); +} + /** * Inline approval action buttons for a profile row. * Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate @@ -225,8 +278,7 @@ export function ProfileApprovalActions({ api.customers.setProfileStatus.mutationOptions(), ); - const act = (next: ProfileStatus) => - mutate({ profileId, status: next }); + const act = (next: ProfileStatus) => mutate({ profileId, status: next }); if (status === "pending") { return ( diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 61b75767a..620b5ec35 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -2,6 +2,7 @@ export { BookingStatusBadge, CompanyStatusBadge, CompanyTypeBadge, + InvoiceStatusBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 6ae71cdc0..8553bcf0b 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -179,7 +179,16 @@ const RuleEngineFormDialog = ({ const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]); const setField = (name: string, value: unknown) => { - setValues((current) => ({ ...current, [name]: value })); + setValues((current) => { + const next = { ...current, [name]: value }; + // Changing what a rate applies to (or its surcharge trigger) can invalidate + // the previously-chosen unit — reset it so the admin re-picks from the new + // allowed set instead of submitting a stale, rejected unit. + if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) { + next.rateUnit = ""; + } + return next; + }); }; const handleSubmit = (event: React.FormEvent) => { @@ -250,6 +259,9 @@ const RuleEngineFormDialog = ({ const label = ; if (field.type === "select") { + // Dynamic options (e.g. rate unit) resolve from the live form values so + // the choices track the other fields the admin has picked. + const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); return ( - {errorText(errors.firstName?.en?.message)} + {stage === "form" ? ( +
+
+

+ Create account +

+

+ Register to access EDR Freight services. +

-
- - + + + + + + - {errorText(errors.lastName?.en?.message)} -
- -
- - - {errorText(errors.email?.message)} -
- -
- - ( -
- field.onChange(v ?? "")} - onBlur={field.onBlur} - /> -
- )} - /> - {errorText(errors.phone?.message)} -
- -
- -
- - -
- {errorText(errors.password?.message)} - {passwordValue.length > 0 ? ( -
- {passwordRequirements.map((req) => { - const met = req.test(passwordValue); - return ( -
- - {met ? : } - - - {req.label} - -
- ); - })} + +
+ + Send verification code via + + setChannel(v as OtpChannel)} + data={[ + { + value: "phone", + label: ( + + Phone + + ), + }, + { + value: "email", + label: ( + + Email + + ), + }, + ]} + />
- ) : null} -
-
- -
- + + {passwordValue.length > 0 ? ( +
+ {passwordRequirements.map((req) => { + const met = req.test(passwordValue); + return ( +
+ + {met ? : } + + + {req.label} + +
+ ); + })} +
+ ) : null} +
+ + - + Continue + + +

+ Already have an account?{" "} + +

+ + + ) : ( + +
+ + +
- {errorText(errors.confirmPassword?.message)} -
- - {error ? ( -
- {error} +
+

+ Verify your {otpChannel === "email" ? "email" : "phone"} +

+

+ We sent a 6-digit code to{" "} + + {otpChannel === "email" + ? maskEmail(pendingData?.email ?? "") + : maskPhone(pendingData?.phone ?? "")} + + . Enter it to finish creating your account. +

- ) : null} - + {otpError ? ( + }> + {otpError} + + ) : null} -

- Already have an account?{" "} - -

-
- + Verify & create account + + +
+ + +
+ + )} +
); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 31ee21ced..9a123e255 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -6,7 +6,6 @@ export type CompanyStep = | "company" | "personnel" | "contact" - | "verify" | "poa" | "documents" | "additional"; @@ -103,7 +102,6 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - verify: [], poa: [], documents: [], additional: [], diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 5dca05b15..725445f57 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -68,6 +68,7 @@ export default function InvoiceDetailPage() { const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">( "TELEBIRR", ); + console.log(paymentMethod) const { data: invoice, @@ -127,7 +128,7 @@ export default function InvoiceDetailPage() { const payable = isPayable(invoice.status); const lines = invoice.lines ?? []; - const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); + // const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); const handlePay = () => { setPayModalOpen(true); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index 9d050b0ad..a023b7850 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -197,6 +197,15 @@ export function BookingPaymentPanel({ : priceTotal(pricing); const items = priceLineItems(pricing); + // Consolidation: this shipment shares a wagon with a partner booking, and the + // wagon is only scheduled once both partners have paid. Surface a note while + // payment is still pending (pay-window open, or a deadline set and not paid). + const showConsolidationNote = + !paid && + Boolean(booking.consolidationPartnerId) && + (booking.status === "SELECTED_FOR_BATCH" || + Boolean(booking.paymentDeadline)); + const { data: invoices = [] } = useQuery({ queryKey: ["booking-invoices", booking.id], queryFn: () => invoicesService.listForSource("booking", booking.id), @@ -268,6 +277,12 @@ export function BookingPaymentPanel({ onPay={onPay} paying={paying} /> + {showConsolidationNote && ( + + This shipment shares a wagon with a consolidation partner — both + shipments must be paid for the wagon to be scheduled. + + )} )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 1b75ab65c..b2f14b16f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -147,6 +147,31 @@ async function searchPlaces( return found; } +/** + * Build the address label for a picked place. + * + * For an establishment / POI (e.g. "Bole Medhanialem") Google's + * `formatted_address` is the *postal* address, which for many Ethiopian places + * collapses to just the city ("Addis Ababa, Ethiopia") — so taking it verbatim + * silently replaces the specific place the user picked with a broad city. The + * place `name` carries the specific label, so we lead with it and only append + * the formatted address for context when it doesn't already contain the name. + * Falls back to the prediction's own description (what the user saw and clicked). + */ +function placeDisplayName( + place: google.maps.places.PlaceResult | null, + prediction: PlacePrediction, +): string { + const name = place?.name?.trim(); + const formatted = place?.formatted_address?.trim(); + if (name && formatted) { + return formatted.toLowerCase().includes(name.toLowerCase()) + ? formatted + : `${name}, ${formatted}`; + } + return name || formatted || prediction.displayName; +} + /** * Resolve a picked prediction to its coordinates via Place Details. Runs once * per selection (closes the Autocomplete session), so billing stays on the @@ -174,10 +199,7 @@ async function resolvePrediction( return; } resolve({ - displayName: - place?.formatted_address || - place?.name || - prediction.displayName, + displayName: placeDisplayName(place, prediction), lat: loc.lat(), lng: loc.lng(), }); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index 29bdef371..f1e5da228 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -11,17 +11,27 @@ import { Loader, Modal, Paper, + PinInput, Stack, Text, TextInput, } from "@mantine/core"; -import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react"; +import { + ArrowLeft, + Download, + FileSignature, + Printer, + RotateCw, + ShieldCheck, +} from "lucide-react"; import toast from "react-hot-toast"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; +import useAuth from "@/hooks/useAuth"; +import { extractApiError } from "@/utils/result"; const CONSENT_TEXT = "I have read the entire contract and agree to its terms."; @@ -34,9 +44,13 @@ export default function ContractViewPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); + const { user } = useAuth(); const iframeRef = useRef(null); const [signOpen, setSignOpen] = useState(false); + const [otpOpen, setOtpOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpError, setOtpError] = useState(null); const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); @@ -44,6 +58,15 @@ export default function ContractViewPage() { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); + // The signed-in customer's registered phone — where the sudo-mode OTP is sent. + const customerPhone = user?.phoneNumber ?? ""; + const maskedPhone = + customerPhone.length > 4 + ? `${customerPhone.slice(0, 4)}${"*".repeat( + Math.max(customerPhone.length - 6, 0), + )}${customerPhone.slice(-2)}` + : customerPhone; + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["contract-view", id], queryFn: () => contractsService.getContractView(id!), @@ -95,6 +118,18 @@ export default function ContractViewPage() { }; }, [checkScrollBottom]); + // Send (or resend) the fresh OTP challenge to the customer's phone. On success + // we swap the signature modal for the OTP entry modal. + const sendOtpMutation = useMutation({ + mutationFn: () => api.auth.sendOTP.call({ phone: customerPhone }), + onSuccess: () => { + setSignOpen(false); + setOtpError(null); + setOtpOpen(true); + }, + onError: () => toast.error("Failed to send verification code"), + }); + const signMutation = useMutation({ mutationFn: () => contractsService.signContract(id!, { @@ -104,16 +139,22 @@ export default function ContractViewPage() { : (signatureData as string), signerDisplayName: signerName.trim(), consentText: CONSENT_TEXT, + otp: otpCode.trim(), + otpPhone: customerPhone, }), onSuccess: () => { - setSignOpen(false); + setOtpOpen(false); + setOtpCode(""); setSuccessOpen(true); void refetch(); void qc.invalidateQueries({ queryKey: api.contracts.get.queryKey({ id: id! }), }); }, - onError: () => toast.error("Failed to sign contract"), + onError: (err) => + setOtpError( + extractApiError(err).message ?? "Failed to verify code and sign", + ), }); const openSign = () => { @@ -128,6 +169,17 @@ export default function ContractViewPage() { if (!signerName.trim()) return; const image = usingSaved ? savedSignatureImage : signatureData; if (!image) return; + if (!customerPhone) { + toast.error("No phone number on file to verify your signature."); + return; + } + setOtpCode(""); + sendOtpMutation.mutate(); + }; + + const confirmOtp = () => { + if (otpCode.trim().length !== 6) return; + setOtpError(null); signMutation.mutate(); }; @@ -315,20 +367,114 @@ export default function ContractViewPage() { + setOtpOpen(false)} + title="Verify it's you" + centered + radius="lg" + > + + + + + + + For security, enter the 6-digit code we sent by SMS to{" "} + + {maskedPhone} + {" "} + to confirm and apply your signature. + + + + {otpError && ( + + {otpError} + + )} + + + + Verification code + + + + + + + + + + + + + + api.contracts.createBookingUnderContract.call({ id: contractId, dto }), @@ -161,6 +165,14 @@ function NewShipmentBookingForm({ }, }); + // Pre-submit validation (container contracts only): warns on overweight + // containers and HARD-BLOCKS on 20ft wagon-pairing errors. Runs each time the + // price modal opens so re-reviewing after an edit re-checks. + const validateMutation = useMutation({ + mutationFn: (dto: Freight.CreateBookingUnderContractDto) => + api.contracts.validateShipment.call({ id: contractId, dto }), + }); + function buildDto( values: ShipmentFormValues, ): Freight.CreateBookingUnderContractDto { @@ -207,13 +219,22 @@ function NewShipmentBookingForm({ }; } - // Submit validates the whole form, then opens the price modal for confirmation. + // Submit validates the whole form, then opens the price modal for + // confirmation. For container contracts we also run the server-side shipment + // validation (overweight warnings + 20ft pairing hard-blocks) so the modal + // can surface them before the booking is created. const handleReview = form.handleSubmit((values) => { setPendingValues(values); + if (isContainerContract) { + validateMutation.reset(); + validateMutation.mutate(buildDto(values)); + } }); const handleConfirm = () => { if (!pendingValues) return; + // Guard: never let a booking with unresolved 20ft pairing errors submit. + if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return; submitMutation.mutate(buildDto(pendingValues)); }; @@ -221,6 +242,7 @@ function NewShipmentBookingForm({ const handleReject = () => { if (submitMutation.isPending) return; setPendingValues(null); + validateMutation.reset(); }; const routes = contract.routes ?? []; @@ -323,6 +345,8 @@ function NewShipmentBookingForm({ contract={contract} values={pendingValues} loading={submitMutation.isPending} + validation={validateMutation.data ?? null} + validationLoading={validateMutation.isPending} onConfirm={handleConfirm} onReject={handleReject} /> @@ -334,20 +358,54 @@ function PriceConfirmModal({ contract, values, loading, + validation, + validationLoading, onConfirm, onReject, }: { contract: Freight.IContract; values: ShipmentFormValues | null; loading: boolean; + validation: ShipmentValidation | null; + validationLoading: boolean; onConfirm: () => void; onReject: () => void; }) { - const total = useMemo( + const baseTotal = useMemo( () => (values ? computeShipmentTotal(contract, values) : null), [contract, values], ); + const overweightLines = validation?.overweightLines ?? []; + const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0; + const pairingErrors = validation?.pairingErrors ?? []; + const hasPairingBlock = pairingErrors.length > 0; + const confirmDisabled = loading || validationLoading || hasPairingBlock; + + // The contract's frozen unit rates (computeShipmentTotal) don't carry an + // overweight line — that surcharge only exists in the live rule engine. Fold + // the real amount from validateShipment into the displayed total so the + // customer sees the actual charge the overweight warning refers to, not just + // the warning text. + const total = useMemo(() => { + if (!baseTotal) return null; + if (!(overweightSurchargeAmount > 0)) return baseTotal; + return { + ...baseTotal, + lines: [ + ...baseTotal.lines, + { + label: "Overweight surcharge", + unitPrice: overweightSurchargeAmount, + unit: "flat" as const, + quantity: 1, + amount: overweightSurchargeAmount, + }, + ], + total: baseTotal.total + overweightSurchargeAmount, + }; + }, [baseTotal, overweightSurchargeAmount]); + return ( {total ? ( + {validationLoading && ( + + + + Checking container weights and wagon pairing… + + + )} + + {hasPairingBlock && ( + } + title="Cannot create booking — 20ft wagon pairing" + > + + {pairingErrors.map((msg, i) => ( + + {msg} + + ))} + + Adjust the 20ft container weights or quantities so pairs differ + by no more than 10 tons. + + + + )} + + {overweightLines.length > 0 && ( + } + title="Overweight containers" + > + + {overweightLines.map((line, i) => ( + + {line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "} + {line.maxAllowedTons}t (+{line.excessTons}t overweight) + + ))} + + {overweightSurchargeAmount > 0 + ? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${ + validation?.currency ?? total?.currency ?? "" + } applies (included in the total below). You can still submit, or go back and adjust weights.` + : "An overweight surcharge applies. You can still submit, or go back and adjust weights."} + + + + )} + {total.lines.map((line, i) => ( @@ -442,6 +557,7 @@ function PriceConfirmModal({ leftSection={} onClick={onConfirm} loading={loading} + disabled={confirmDisabled} > Confirm & book diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 4d5c4b759..2b31559ce 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -1,13 +1,16 @@ +import { fileViewUrl } from "@/constants/apiConfig"; import { api } from "@/services/api"; import { companiesService } from "@/services/companies.service"; import { getMinFiles } from "@/types/fileUploadSettings"; import type { ProfileResponse } from "@/types/profile"; -import { SmartFileInput } from "@edr/ui-common"; +import { SmartFileInput, useFileViewer } from "@edr/ui-common"; import { + // Anchor, Button, Card, Center, Group, + Stack, Text, Title, } from "@mantine/core"; @@ -17,10 +20,19 @@ import { CheckCircle2, FileCheck, Loader2, + Paperclip, UploadCloud, XCircle, } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; + +const ROLE_LABELS: Record = { + importer: "Importer", + exporter: "Exporter", + freight_forwarder: "Freight Forwarder", + dj_freight_forwarder: "DJ Freight Forwarder", + transporter: "Transporter", +}; interface TabDocumentsProps { profile: ProfileResponse; @@ -28,21 +40,63 @@ interface TabDocumentsProps { onContinue?: () => void; } -export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) { +function documentSettingCode(nationality: string | null | undefined): string { + return nationality === "foreign" + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; +} + +export default function TabDocuments({ + profile, + mode = "edit", + onContinue, +}: TabDocumentsProps) { const queryClient = useQueryClient(); - const [documentFiles, setDocumentFiles] = useState>({}); + const { view, viewer } = useFileViewer(); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); const docSettingQuery = useQuery( api.fileUploadSettings.getByCode.queryOptions({ - input: { code: "customer_file_documents" }, + input: { code: documentSettingCode(profile.nationality) }, }), ); + const docsQuery = useQuery( + api.companies.documents.queryOptions({ + input: { companyId: profile.companyId }, + }), + ); + + const uploadedKeys = useMemo( + () => (docsQuery.data ?? []).map((d) => d.code), + [docsQuery.data], + ); + + const existingFilesByKey = useMemo(() => { + const map: Record< + string, + { name: string; url: string; size?: number; mimeType?: string | null }[] + > = {}; + for (const doc of docsQuery.data ?? []) { + (map[doc.code] ??= []).push({ + name: doc.name, + url: fileViewUrl(doc.id), + size: doc.size, + mimeType: doc.mimeType, + }); + } + return map; + }, [docsQuery.data]); + const docUploadMutation = useMutation({ mutationFn: (files: Record) => companiesService.uploadDocuments(profile.companyId, files), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); }, }); @@ -73,6 +127,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab for (const field of docSettingQuery.data?.fields ?? []) { const min = getMinFiles(field); if (min <= 0) continue; + if (uploadedKeys.includes(field.fileKey)) continue; const v = documentFiles[field.fileKey]; const count = Array.isArray(v) ? v.length : v ? 1 : 0; if (count < min) { @@ -82,94 +137,139 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab return errs; }; + const licenseProfiles = profile.companyProfiles.filter( + (p) => p.licenseFiles && p.licenseFiles.length > 0, + ); + return ( - - - - Documents - - - Upload and manage required business documents - - - {docSettingQuery.isLoading ? ( -
- -
- ) : !docSettingQuery.data ? ( - - No document requirements configured for your account. + <> + + + + Documents + + + Upload and manage required business documents - ) : ( - - )} - {docSettingQuery.data && ( - - - {docUploadMutation.isSuccess && ( - - - - {mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"} - - - )} - {docUploadMutation.isError && ( - - - Upload failed - + {docSettingQuery.isLoading ? ( +
+ +
+ ) : !docSettingQuery.data ? ( + + No document requirements configured for your account. + + ) : ( + + )} + + {docSettingQuery.data && ( + + + {docUploadMutation.isSuccess && ( + + + + {mode === "onboarding" + ? "Saved successfully" + : "Documents uploaded successfully"} + + + )} + {docUploadMutation.isError && ( + + + + Upload failed + + + )} + + {mode === "onboarding" ? ( + + ) : ( + )} - {mode === "onboarding" ? ( - - ) : ( - - )} -
+ )} +
+ + {licenseProfiles.length > 0 && ( + + + + Business licenses + + + License documents uploaded per operational profile + + + + {licenseProfiles.map((p) => ( + + + {ROLE_LABELS[p.type] ?? p.type} · {p.reference} + + {p.licenseFiles.map((f) => ( + + + + {f.name} + + + ))} + + ))} + + )} -
+ + {viewer} + ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a09bd4b4e..ed359d998 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -24,6 +24,7 @@ import { ContractDocuments, GenerateContractPriceResponse, SubmitContractResponse, + ShipmentValidation, } from "./contracts.service"; import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema"; import { @@ -53,6 +54,7 @@ import { UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; import type { + CompanyDocument, CompanyInfoResponse, CompanyNationality, CompanyProfileResponse, @@ -158,7 +160,11 @@ export const api = { createCompanyProfile: endpoint< { type: ProfileTypeValue; businessLicense?: string }, CompanyProfileResponse - >("companies", "createCompanyProfile", companiesService.createCompanyProfile), + >( + "companies", + "createCompanyProfile", + companiesService.createCompanyProfile, + ), startOnboarding: endpoint< { @@ -192,6 +198,12 @@ export const api = { "onboardingRequirements", companiesService.getOnboardingRequirements, ), + + documents: endpoint<{ companyId: string }, CompanyDocument[]>( + "companies", + "documents", + ({ companyId }) => companiesService.getDocuments(companyId), + ), }, bookings: { @@ -228,7 +240,8 @@ export const api = { downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>( "bookings", "downloadHandoverDocument", - ({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId), + ({ inventoryId }) => + bookingsService.downloadHandoverDocument(inventoryId), ), create: endpoint< @@ -312,11 +325,8 @@ export const api = { proceedToOperation: endpoint< { id: string; scheduledDate: string }, Freight.IBooking - >( - "bookings", - "proceedToOperation", - ({ id, scheduledDate }) => - bookingsService.proceedToOperation(id, scheduledDate), + >("bookings", "proceedToOperation", ({ id, scheduledDate }) => + bookingsService.proceedToOperation(id, scheduledDate), ), checkPayment: endpoint<{ orderId: string }, { status: string }>( @@ -354,10 +364,11 @@ export const api = { bookingsService.getAvailableDays({ originYardId, destinationYardId }), ), - getAvailableDaysForCargo: endpoint( - "train-scheduling", - "availableDaysForCargo", - (input) => bookingsService.getAvailableDaysForCargo(input), + getAvailableDaysForCargo: endpoint< + Freight.AvailableDaysForCargoQuery, + string[] + >("train-scheduling", "availableDaysForCargo", (input) => + bookingsService.getAvailableDaysForCargo(input), ), getMyBookingWindows: endpoint( @@ -462,6 +473,13 @@ export const api = { contractsService.createBookingUnderContract(id, dto), ), + validateShipment: endpoint< + { id: string; dto: Freight.CreateBookingUnderContractDto }, + ShipmentValidation + >("contracts", "validateShipment", ({ id, dto }) => + contractsService.validateShipment(id, dto), + ), + getContractMilestones: endpoint< { id: string }, Freight.IClearanceMilestone[] diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 225f14e15..a4ce87131 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -89,6 +89,10 @@ export interface SignContractPayload { signatureImageBase64: string; signerDisplayName: string; consentText?: string; + /** Sudo-mode OTP challenge; required when role=CUSTOMER. */ + otp?: string; + /** Phone the OTP was sent to; required when role=CUSTOMER. */ + otpPhone?: string; } export interface ApproveDeliveryResponse { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index d326811b8..d3f584170 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -82,6 +82,18 @@ export interface CompanyInfoResponse { company: CompanyResponse; } +/** A single company-level document uploaded against a `file_upload_settings` field. */ +export interface CompanyDocument { + id: string; + name: string; + /** The `fileKey` of the setting field it was uploaded against. */ + code: string; + mimeType: string; + size: number; + uploadedAt: string; + url: string; +} + /** A single onboarding document field, as resolved and described by the backend. */ export interface OnboardingDocumentField { fileKey: string; @@ -124,7 +136,12 @@ export interface OnboardingRequirements { } export interface CompanyProfileInput { - type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; + type: + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; businessLicense?: string; } @@ -180,7 +197,9 @@ export const companiesService = { } }, - create: async (payload: CreateCompanyPayload): Promise => { + create: async ( + payload: CreateCompanyPayload, + ): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.CREATE, payload, @@ -195,7 +214,9 @@ export const companiesService = { return unwrap(response.data); }, - updateProfile: async (payload: UpdateProfilePayload): Promise => { + updateProfile: async ( + payload: UpdateProfilePayload, + ): Promise => { const response = await client.patch>( URL_CONSTANTS.COMPANIES_API.PROFILE, payload, @@ -293,7 +314,18 @@ export const companiesService = { formData.append(fieldName, fileOrFiles); } } - await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData); + await client.post( + URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), + formData, + ); + }, + + /** List documents already uploaded for a company (settings-driven, by fileKey). */ + getDocuments: async (companyId: string): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), + ); + return unwrap(response.data); }, /** Upload business-license document(s) for a company profile (multi-file). */ diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 600c14860..7c981eee2 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -33,6 +33,29 @@ export interface SubmitContractResponse { message?: string; } +/** A container line whose total VGM exceeds the weight-limit rule. */ +export interface OverweightLine { + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; +} + +/** + * Pre-submit validation for a shipment booking under a CONTAINER contract. + * `overweightLines` are WARNINGS only (an overweight surcharge applies — the + * customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers + * that cannot be balanced onto wagons) and must prevent booking. + * `overweightSurchargeAmount` is the real overweight charge (same rate the + * booking is billed at on submit) so the confirm-modal total can include it. + */ +export interface ShipmentValidation { + overweightLines: OverweightLine[]; + overweightSurchargeAmount: number; + currency: string | null; + pairingErrors: string[]; +} + export interface ContractListFilter { status?: string; statuses?: string; @@ -285,6 +308,20 @@ export const contractsService = { return data.data.booking ?? data.data; }, + /** + * Pre-submit validation of a shipment booking (same DTO as + * `createBookingUnderContract`). Returns overweight warnings and hard-block + * 20ft wagon-pairing errors so the customer can be warned/blocked before the + * booking is created. + */ + validateShipment: async ( + id: string, + dto: Freight.CreateBookingUnderContractDto, + ): Promise => { + const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto); + return data.data ?? data; + }, + // ── Milestones ── getContractMilestones: async ( id: string, diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index e9a84dd93..7b58f573b 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -33,7 +33,9 @@ export interface SignupResponse { } export interface OtpPayload { - phone: string; + /** Exactly one of phone/email — the channel the code is sent through. */ + phone?: string; + email?: string; /** Required on verify; omitted on send (the server generates the code). */ otp?: string; } diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index d21dab37f..45a52dd8f 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -48,6 +48,7 @@ "class-validator": "^0.14.0", "dotenv": "^17.4.2", "express": "^4.18.2", + "helmet": "^8.0.0", "jose": "^5.10.0", "pg": "^8.21.0", "qrcode": "^1.5.3", diff --git a/apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql new file mode 100644 index 000000000..232f9b956 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260702163828_add_route_coach_template/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable: change distanceKm from Decimal to Double Precision on RouteStop +ALTER TABLE "RouteStop" ALTER COLUMN "distanceKm" TYPE DOUBLE PRECISION; + +-- CreateTable +CREATE TABLE "RouteCoachTemplate" ( + "id" TEXT NOT NULL, + "routeId" TEXT NOT NULL, + "coachId" TEXT NOT NULL, + "positionNumber" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RouteCoachTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RouteCoachTemplate_routeId_idx" ON "RouteCoachTemplate"("routeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RouteCoachTemplate_routeId_positionNumber_key" ON "RouteCoachTemplate"("routeId", "positionNumber"); + +-- AddForeignKey +ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql new file mode 100644 index 000000000..b70865401 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260702165853_add_seat_class_nationality_bed_position/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "SeatClass" ADD COLUMN "bedPosition" TEXT, +ADD COLUMN "nationalityType" TEXT; + +-- CreateIndex +CREATE INDEX "SeatClass_coachTypeId_nationalityType_bedPosition_idx" ON "SeatClass"("coachTypeId", "nationalityType", "bedPosition"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 82cd15f89..68b3ac234 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -88,7 +88,9 @@ model SeatClass { coachTypeId String name String description String? - baseFareMinor Int @default(0) // per-km rate + nationalityType String? // 'LOCAL' | 'INTERNATIONAL' + bedPosition String? // 'UPPER' | 'MIDDLE' | 'LOWER' | null for regular seat + baseFareMinor Int @default(0) // per-km rate (tariff decimal × 100000) premiumMinor Int @default(0) // flat fee per passenger insuranceFeeMinor Int @default(0) // flat fee per passenger isActive Boolean @default(true) @@ -100,6 +102,7 @@ model SeatClass { segmentFares SegmentFareRule[] @@unique([coachTypeId, name]) @@index([coachTypeId]) + @@index([coachTypeId, nationalityType, bedPosition]) @@schema("passenger") } @@ -420,9 +423,10 @@ model Coach { status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - coachType CoachType @relation(fields: [coachTypeId], references: [id]) - seats Seat[] - assignments CoachAssignment[] + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + seats Seat[] + assignments CoachAssignment[] + routeTemplates RouteCoachTemplate[] @@index([coachTypeId]) @@index([sequence]) @@schema("passenger") @@ -998,6 +1002,7 @@ model Route { fareRules RouteFareRule[] segmentFares SegmentFareRule[] schedules TrainSchedule[] + coachTemplates RouteCoachTemplate[] @@schema("passenger") } @@ -1015,6 +1020,20 @@ model RouteStop { @@schema("passenger") } +model RouteCoachTemplate { + id String @id @default(uuid()) + routeId String + coachId String + positionNumber Int + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + coach Coach @relation(fields: [coachId], references: [id]) + + @@unique([routeId, positionNumber]) + @@index([routeId]) + @@schema("passenger") +} + model RouteFareRule { id String @id @default(uuid()) routeId String diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index c898e8231..0e9f85079 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -166,21 +166,41 @@ async function seedCoachTypesAndClasses() { }); } + // Tariff rates: baseFareMinor = tariff_decimal × 100000 + // Formula: fare = km × (baseFareMinor / 100000) × 1.02 × exchangeRate + // LOCAL = Ethiopian or Djiboutian nationals + // INTERNATIONAL = all other nationalities const seatClasses = [ - { name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 }, - { name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 }, - { name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 }, - { name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 }, - { name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 }, - { name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 }, + // LOCAL rates + { name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Upper (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 7500, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Lower (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 }, + // INTERNATIONAL rates + { name: 'Economy Regular (Intl)', coachCode: 'HSC', nationalityType: 'INTERNATIONAL', bedPosition: null, baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Upper (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Middle (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'MIDDLE',baseFareMinor: 11000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'Economy Bed Lower (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 12000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Upper (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 15000, premiumMinor: 0, insuranceFeeMinor: 0 }, + { name: 'VIP Bed Lower (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 16000, premiumMinor: 0, insuranceFeeMinor: 0 }, ]; for (const sc of seatClasses) { const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } }); await prisma.seatClass.upsert({ where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } }, - update: {}, - create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor }, + update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor }, + create: { + coachTypeId: ct!.id, + name: sc.name, + nationalityType: sc.nationalityType, + bedPosition: sc.bedPosition, + baseFareMinor: sc.baseFareMinor, + premiumMinor: sc.premiumMinor, + insuranceFeeMinor: sc.insuranceFeeMinor, + }, }); } console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`); @@ -238,6 +258,58 @@ async function seedRoute() { }); } console.log(` ✅ Route with ${returnStationCodes.length} stops created`); + + // Full cross-border route: Sebeta → Nagad (all 15 stations) + const fullRoute = await prisma.route.upsert({ + where: { code: 'Route-201' }, + update: {}, + create: { + code: 'Route-201', + name: 'Sebeta - Nagad (Full Cross-Border)', + description: 'Full Ethio-Djibouti cross-border route from Sebeta to Nagad', + effectiveFrom: new Date('2026-01-01'), + effectiveUntil: new Date('2034-12-31'), + active: true, + }, + }); + + // Cumulative distances from Sebeta (km) for all 15 stations + const fullStationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG']; + const fullDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0, 453.0, 498.0, 531.0, 601.0, 632.0, 656.0]; + for (let i = 0; i < fullStationCodes.length; i++) { + const station = await prisma.station.findUnique({ where: { code: fullStationCodes[i] } }); + await prisma.routeStop.upsert({ + where: { routeId_sequence: { routeId: fullRoute.id, sequence: i + 1 } }, + update: { distanceKm: fullDistancesKm[i] }, + create: { routeId: fullRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullDistancesKm[i] }, + }); + } + + // Full cross-border return route: Nagad → Sebeta + const fullReturnRoute = await prisma.route.upsert({ + where: { code: 'Route-202' }, + update: {}, + create: { + code: 'Route-202', + name: 'Nagad - Sebeta (Full Cross-Border Return)', + description: 'Full Ethio-Djibouti cross-border return route from Nagad to Sebeta', + effectiveFrom: new Date('2026-01-01'), + effectiveUntil: new Date('2034-12-31'), + active: true, + }, + }); + + const fullReturnStationCodes = ['NAG', 'HOL', 'ALS', 'DAW', 'AYS', 'ADG', 'DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT']; + const fullReturnDistancesKm = [0, 24.0, 55.0, 125.0, 158.0, 203.0, 243.0, 362.4, 424.4, 475.8, 549.3, 566.1, 588.8, 644.5, 656.0]; + for (let i = 0; i < fullReturnStationCodes.length; i++) { + const station = await prisma.station.findUnique({ where: { code: fullReturnStationCodes[i] } }); + await prisma.routeStop.upsert({ + where: { routeId_sequence: { routeId: fullReturnRoute.id, sequence: i + 1 } }, + update: { distanceKm: fullReturnDistancesKm[i] }, + create: { routeId: fullReturnRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullReturnDistancesKm[i] }, + }); + } + console.log(` ✅ Full cross-border routes (Route-201, Route-202) with 15 stops each created`); } async function seedCoaches() { @@ -431,34 +503,61 @@ async function seedTrips() { async function seedFareRules() { console.log('\n💰 Seeding fare rules...'); const route = await prisma.route.findUnique({ where: { code: 'Route-101' } }); + const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } }); const seatClasses = await prisma.seatClass.findMany(); const validFrom = new Date('2024-01-01'); - const fareRules = []; - for (const sc of seatClasses) { - fareRules.push({ - routeId: route!.id, - seatClassId: sc.id, - passengerCategory: 'ADULT' as const, - baseFareMinor: sc.baseFareMinor, - currency: 'ETB', - validFrom, - }); - fareRules.push({ - routeId: route!.id, - seatClassId: sc.id, - passengerCategory: 'CHILD' as const, - baseFareMinor: Math.floor(sc.baseFareMinor * 0.5), - discountPercent: 10, - currency: 'ETB', - validFrom, - }); + // Delete existing FareRule rows so re-seed is idempotent + await prisma.fareRule.deleteMany({}); + + const fareRules: any[] = []; + for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) { + for (const sc of seatClasses) { + fareRules.push({ + route: route.code, + seatClassId: sc.id, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + } } await Promise.all( - fareRules.map(fr => prisma.routeFareRule.create({ data: fr })) + fareRules.map(fr => prisma.fareRule.create({ data: fr })) ); - console.log(` ✅ ${fareRules.length} fare rules for ADULT/CHILD categories created`); + console.log(` ✅ ${fareRules.length} fare rules created in FareRule table`); + + const allRoutes = await prisma.route.findMany({ + where: { code: { in: ['Route-101', 'Route-102', 'Route-201', 'Route-202'] } }, + }); + const routeFareRules: any[] = []; + for (const r of allRoutes) { + for (const sc of seatClasses) { + routeFareRules.push({ + routeId: r.id, + seatClassId: sc.id, + passengerCategory: 'ADULT' as const, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + // CHILD: same per-km rate as ADULT — age-based free/paid logic is handled + // at booking time (first child free, subsequent children full fare). + routeFareRules.push({ + routeId: r.id, + seatClassId: sc.id, + passengerCategory: 'CHILD' as const, + baseFareMinor: sc.baseFareMinor, + currency: 'ETB', + validFrom, + }); + } + } + await Promise.all( + routeFareRules.map(fr => prisma.routeFareRule.create({ data: fr }).catch(() => {})) + ); + console.log(` ✅ ${routeFareRules.length} route fare rules for ADULT/CHILD categories created`); } async function seedCurrency() { @@ -758,7 +857,23 @@ async function runStep(name: string, step: () => Promise): Promise Promise]> = [ + const steps: Array<[string, () => Promise]> = [ + ['System Users', seedSystemUsers], + ['Stations', seedStations], + ['Coach Types & Classes', seedCoachTypesAndClasses], + ['Route', seedRoute], + ['Coaches', seedCoaches], + ['Trips', seedTrips], + ['Fare Rules', seedFareRules], + ['Currency', seedCurrency], + ['Payment Methods', seedPaymentMethods], + ['Segment Fares', seedSegmentFares], + ['Notification Templates', seedNotificationTemplates], + ['Menu & Food', seedMenuAndFood], + ['Promotions', seedPromotions], + ['FAQ', seedFAQ], + ['Fraud Rules', seedFraudRules], + ['Kulubbi Package', seedKulubbiPackage], ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index f4bac0f0b..ba7a18086 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -1,9 +1,4 @@ -import { - MiddlewareConsumer, - Module, - NestModule, - OnApplicationBootstrap, -} from '@nestjs/common'; +import {Logger, Module, OnApplicationBootstrap} from '@nestjs/common'; import { ThrottlerModule } from '@nestjs/throttler'; import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard'; import { APP_GUARD, APP_FILTER } from '@nestjs/core'; @@ -66,7 +61,6 @@ import { PackagesModule } from './modules/packages/packages.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { HealthModule } from './modules/health/health.module'; import { TasksModule } from './modules/tasks/tasks.module'; -import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; @Module({ imports: [ @@ -136,7 +130,6 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable ExcessBaggageModule, HealthModule, TasksModule, - ConfigurableFareModule, ], providers: [ { provide: APP_GUARD, useClass: DynamicThrottlerGuard }, @@ -147,6 +140,7 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable ], }) export class AppModule implements OnApplicationBootstrap { + private readonly logger = new Logger(AppModule.name); constructor( private readonly seeder: DataSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, @@ -157,17 +151,17 @@ export class AppModule implements OnApplicationBootstrap { try { await this.seeder.run(); } catch (err) { - console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); } try { await this.edrPassengerOrgSeeder.run(); } catch (err) { - console.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message); } try { await this.passengerStaffUsersSeeder.run(); } catch (err) { - console.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); + this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); } } } diff --git a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts index 7a450bfaf..5ad8232ec 100644 --- a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts +++ b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts @@ -3,6 +3,17 @@ import { Reflector } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler'; import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service'; +// Route-prefix → throttler tier mapping. +// Evaluated in order; first match wins. +const ROUTE_TIERS: Array<{ prefix: string; tier: 'auth' | 'strict' | 'default' }> = [ + { prefix: '/auth', tier: 'auth' }, + { prefix: '/fayda/verification',tier: 'auth' }, + { prefix: '/bookings', tier: 'strict' }, + { prefix: '/passengers', tier: 'strict' }, + { prefix: '/payments', tier: 'strict' }, + { prefix: '/wallet', tier: 'strict' }, +]; + @Injectable() export class DynamicThrottlerGuard extends ThrottlerGuard { constructor( @@ -29,9 +40,17 @@ export class DynamicThrottlerGuard extends ThrottlerGuard { this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS), ]); - this.throttlers = [ - { name: 'default', ttl: defaultTtl, limit: defaultLimit }, - ]; + const url: string = context.switchToHttp().getRequest<{ url: string }>().url ?? ''; + const matched = ROUTE_TIERS.find(({ prefix }) => url.startsWith(prefix)); + const tier = matched?.tier ?? 'default'; + + if (tier === 'auth') { + this.throttlers = [{ name: 'auth', ttl: authTtl, limit: authLimit }]; + } else if (tier === 'strict') { + this.throttlers = [{ name: 'strict', ttl: strictTtl, limit: strictLimit }]; + } else { + this.throttlers = [{ name: 'default', ttl: defaultTtl, limit: defaultLimit }]; + } return super.canActivate(context); } diff --git a/apps/edr-passenger-api/src/common/prisma.service.ts b/apps/edr-passenger-api/src/common/prisma.service.ts index 75d4eaa24..9789791ad 100644 --- a/apps/edr-passenger-api/src/common/prisma.service.ts +++ b/apps/edr-passenger-api/src/common/prisma.service.ts @@ -1,8 +1,29 @@ -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name); + + constructor() { + super({ + // In production set connection_limit and pool_timeout in DATABASE_URL: + // ?connection_limit=10&pool_timeout=20&sslmode=require + log: + process.env.NODE_ENV === 'development' + ? [{ emit: 'event', level: 'query' }, { emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }] + : [{ emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }], + }); + + if (process.env.NODE_ENV === 'development') { + (this as any).$on('query', (e: { query: string; duration: number }) => { + if (e.duration > 500) { + this.logger.warn(`Slow query (${e.duration}ms): ${e.query}`); + } + }); + } + } + async onModuleInit() { await this.$connect(); } async onModuleDestroy() { await this.$disconnect(); } } diff --git a/apps/edr-passenger-api/src/config/app.config.ts b/apps/edr-passenger-api/src/config/app.config.ts index 9203a9d7b..5ed75a2b3 100644 --- a/apps/edr-passenger-api/src/config/app.config.ts +++ b/apps/edr-passenger-api/src/config/app.config.ts @@ -1,9 +1,23 @@ import { registerAs } from '@nestjs/config'; -export default registerAs('app', () => ({ - port: parseInt(process.env.PORT ?? '4000', 10), - jwtSecret: process.env.JWT_SECRET ?? 'dev-secret', - jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d', - frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000', - portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001', -})); +export default registerAs('app', () => { + const isProd = process.env.NODE_ENV === 'production'; + + if (isProd && !process.env.JWT_SECRET) { + throw new Error('JWT_SECRET environment variable is required in production'); + } + if (isProd && !process.env.JWT_ACCESS_TOKEN_SECRET) { + throw new Error('JWT_ACCESS_TOKEN_SECRET environment variable is required in production'); + } + if (isProd && !process.env.JWT_REFRESH_TOKEN_SECRET) { + throw new Error('JWT_REFRESH_TOKEN_SECRET environment variable is required in production'); + } + + return { + port: parseInt(process.env.PORT ?? '4000', 10), + jwtSecret: process.env.JWT_SECRET ?? 'dev-secret', + jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d', + frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000', + portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001', + }; +}); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 004ef1336..cbdae37e3 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -1,11 +1,12 @@ -// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM +// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM // modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT), // which happens before ConfigModule.forRoot() would populate it. Must be the very first import. import "dotenv/config"; import "reflect-metadata"; import { NestFactory } from "@nestjs/core"; -import { ValidationPipe, VersioningType } from "@nestjs/common"; +import { Logger, ValidationPipe, VersioningType } from "@nestjs/common"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import helmet from "helmet"; import { AppModule } from "./app.module"; import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor"; @@ -14,21 +15,32 @@ import { SessionActivityInterceptor } from "./common/interceptors/session-activi // Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations process.env.TZ = 'Africa/Addis_Ababa'; +// Safety guard: prevent insecure TLS from being enabled in production +if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS === 'true') { + throw new Error('WAAFI_INSECURE_TLS=true is not allowed in production'); +} + async function bootstrap() { // rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. const app = await NestFactory.create(AppModule, { rawBody: true }); + // Security headers + app.use(helmet()); + // URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under // `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay - // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. + // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. app.enableVersioning({ type: VersioningType.URI }); app.enableCors({ origin: [ process.env.PORTAL_URL ?? "http://localhost:5174", - process.env.BACK_OFFICE_URL ?? "http://localhost:5184", + process.env.BACK_OFFICE_URL ?? "http://localhost:5184", ], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Accept-Language', 'X-Request-ID'], + credentials: true, }); app.useGlobalFilters(new HttpExceptionFilter()); @@ -38,6 +50,7 @@ async function bootstrap() { ); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false })); + if (process.env.NODE_ENV !== 'production') { const config = new DocumentBuilder() .setTitle("EDR Passenger API") .setDescription( @@ -130,7 +143,7 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) - Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets - Complete audit trail per leg for compliance and reporting -- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode +- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode ### Booking Type Matrix @@ -240,28 +253,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret ### Step 3: Passenger Information & Verification **For Ethiopian Passengers:** -\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years) +\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years) **For International Passengers:** -\`POST /passengers/register-international\` — Passport information collection +\`POST /passengers/register-international\` — Passport information collection ### Step 4: View Seat Map -\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats. +\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats. For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId. ### Step 5: Hold Seats \`POST /seats/hold\` to reserve seats for 15 minutes. -- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\` -- TRANSIT leg-2: second hold call → \`leg2HoldId\` -- ROUND_TRIP return: second hold call → \`returnHoldId\` -- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\` +- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\` +- TRANSIT leg-2: second hold call → \`leg2HoldId\` +- ROUND_TRIP return: second hold call → \`returnHoldId\` +- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\` ### Step 6: Create Booking Choose the right endpoint and bookingType: -- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\` -- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\` -- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\` -- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\` +- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\` +- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\` +- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\` +- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\` ### Step 7: Process Payment \`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian) @@ -406,10 +419,12 @@ Payment providers send notifications to: operationsSorter: "alpha", }, }); + } // end if (NODE_ENV !== 'production') const port = process.env.PORT ?? 4000; await app.listen(port); - console.log(`🚀 EDR Passenger API running on port ${port}`); - console.log(`📚 Swagger: http://localhost:${port}/api-docs`); + const logger = new Logger('Bootstrap'); + logger.log(`EDR Passenger API running on port ${port}`); + logger.log(`Swagger: http://localhost:${port}/api-docs`); } bootstrap(); diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 4dd131f3e..0213bb8f6 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -180,6 +180,9 @@ export class PassengerAuthService { return { iamUserId, + // Top-level passengerId keeps the profile shape consistent with the login + // response so the web User object always carries it (the JWT does not). + passengerId: passenger.id, email: iam?.email ?? null, phone: iam?.phone_number ?? null, fullName: iam?.name?.en ?? iam?.name?.am ?? null, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 4062acddc..5df5f7107 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator'; +import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDate, MaxDate } from 'class-validator'; import { Type, Transform } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; @@ -10,10 +10,12 @@ export class PassengerInputDto { @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' }) - @IsDateString() - @Transform(({ value }) => value) + // Incoming value is an ISO date string (YYYY-MM-DD); transform to a Date so + // @MaxDate (which requires an actual Date instance) evaluates correctly. + @Transform(({ value }) => (value ? new Date(value) : value)) + @IsDate() @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) - dateOfBirth: string; + dateOfBirth: Date; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @@ -44,10 +46,12 @@ export class RoundTripPassengerDto { example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' }) - @IsDateString() - @Transform(({ value }) => value) + // Incoming value is an ISO date string (YYYY-MM-DD); transform to a Date so + // @MaxDate (which requires an actual Date instance) evaluates correctly. + @Transform(({ value }) => (value ? new Date(value) : value)) + @IsDate() @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) - dateOfBirth: string; + dateOfBirth: Date; @ApiProperty({ example: 'NATIONAL_ID', diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts index 909452144..0adb94656 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { CurrenciesController } from './currencies.controller'; import { CurrenciesService } from './currencies.service'; +import { CurrencyModule } from '../currency/currency.module'; +import { PrismaModule } from '../../common/prisma.module'; @Module({ - imports: [HttpModule], + imports: [HttpModule, PrismaModule, CurrencyModule], controllers: [CurrenciesController], providers: [CurrenciesService], exports: [CurrenciesService], diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index ee96ee9cd..2ccc79b70 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -1,10 +1,14 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; +import { CurrencyService } from '../currency/currency.service'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; @Injectable() export class CurrenciesService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private currencyService: CurrencyService, + ) {} async getAllCurrencies() { const rates = await this.prisma.currencyExchangeRate.findMany({ @@ -108,7 +112,12 @@ export class CurrenciesService { } async syncExchangeRates() { - return { message: 'Exchange rates synced successfully', synced: 0 }; + await this.currencyService.syncExchangeRates(); + const rates = await this.prisma.currencyExchangeRate.findMany({ + orderBy: { effectiveDate: 'desc' }, + take: 10, + }); + return { message: 'Exchange rates synced successfully', synced: rates.length }; } private getCurrencyName(code: string): string { diff --git a/apps/edr-passenger-api/src/modules/currency/currency.module.ts b/apps/edr-passenger-api/src/modules/currency/currency.module.ts index 445f31e05..635ab74b0 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.module.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { CurrencyService } from './currency.service'; import { PrismaModule } from '../../common/prisma.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, HttpModule], providers: [CurrencyService], exports: [CurrencyService], }) diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 8ac2056ab..4666d4aaa 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -4,6 +4,9 @@ import { NotFoundException, BadRequestException, } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; import { PrismaService } from '../../common/prisma.service'; import { Currency } from '@prisma/client'; @@ -21,7 +24,11 @@ const CHARGE_CURRENCY_DECIMALS: Record = { export class CurrencyService { private readonly logger = new Logger(CurrencyService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) {} async convertEtbMinorToChargeMajor( amountMinorEtb: number, @@ -81,14 +88,11 @@ export class CurrencyService { fromCurrency: Currency, toCurrency: Currency, ): Promise { + if (fromCurrency === toCurrency) return 1; + const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ - where: { - fromCurrency, - toCurrency, - }, - orderBy: { - effectiveDate: 'desc', - }, + where: { fromCurrency, toCurrency }, + orderBy: { effectiveDate: 'desc' }, }); if (!exchangeRate) { @@ -98,26 +102,61 @@ export class CurrencyService { return 1.0; } + const ageMs = Date.now() - exchangeRate.effectiveDate.getTime(); + if (ageMs > 2 * 24 * 60 * 60 * 1000) { + this.logger.warn( + `Stale exchange rate for ${fromCurrency}->${toCurrency}: last updated ${exchangeRate.effectiveDate.toISOString()}`, + ); + } + return Number(exchangeRate.rate); } async syncExchangeRates(): Promise { - this.logger.log('Syncing exchange rates from external provider'); + this.logger.log('Syncing exchange rates from central bank API'); const today = this.todayUtc(); - const rates = [ - { from: 'ETB', to: 'ETB', rate: 1.0 }, - { from: 'ETB', to: 'DJF', rate: 3.25 }, - { from: 'ETB', to: 'USD', rate: 0.018 }, - { from: 'DJF', to: 'ETB', rate: 0.3077 }, - { from: 'USD', to: 'ETB', rate: 55.56 }, + // Fallback rates used when the API is unreachable + const fallbackRates = [ + { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, + { from: Currency.ETB, to: Currency.DJF, rate: 3.25 }, + { from: Currency.ETB, to: Currency.USD, rate: 0.018 }, + { from: Currency.DJF, to: Currency.ETB, rate: 0.3077 }, + { from: Currency.USD, to: Currency.ETB, rate: 55.56 }, ]; - for (const { from, to, rate } of rates) { - await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API'); + const apiUrl = this.configService.get('EXCHANGE_RATE_API_URL'); + if (apiUrl) { + try { + const response = await firstValueFrom( + this.httpService.get>(apiUrl, { timeout: 5000 }), + ); + // Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... } + const data = response.data; + const apiRates = [ + { from: Currency.ETB, to: Currency.ETB, rate: 1.0 }, + { from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate }, + { from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate }, + { from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate }, + { from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate }, + ]; + for (const { from, to, rate } of apiRates) { + await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API'); + } + this.logger.log('Exchange rates synced from central bank API'); + return; + } catch (err) { + this.logger.warn( + `Central bank API unreachable (${(err as Error).message}), falling back to configured rates`, + ); + } } - this.logger.log('Exchange rates synced successfully'); + // Fallback: persist the static rates so the DB always has a current row + for (const { from, to, rate } of fallbackRates) { + await this.upsertRate(from, to, rate, today, 'FALLBACK'); + } + this.logger.log('Exchange rates synced using fallback values'); } async listRates() { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 847164551..592aadacd 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -4,8 +4,6 @@ import { CurrencyService } from '../currency/currency.service'; import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto'; import { Currency } from '@prisma/client'; -const TAX_RATE = 0.05; - @Injectable() export class FareEngineService { constructor( @@ -32,6 +30,22 @@ export class FareEngineService { if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); + // Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL + const nationalityUpper = (dto.nationality ?? '').toUpperCase(); + const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + + // Find the nationality-specific seat class for the same coach type and bed position. + // Falls back to the requested seatClass if no nationality-specific one exists. + const nationalitySeatClass = await this.prisma.seatClass.findFirst({ + where: { + coachTypeId: seatClass.coachTypeId, + nationalityType, + bedPosition: seatClass.bedPosition ?? null, + isActive: true, + }, + }) ?? seatClass; + // Calculate distance: distanceKm represents cumulative distance from route origin // For a segment, distance = destination.distanceKm - origin.distanceKm const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; @@ -68,16 +82,45 @@ export class FareEngineService { let ratePerKmMinor: number; let fareSource: string; - if (fareRule) { - // Flat fare from FareRule — distance is informational only + // 1. Segment override: exact origin→destination stop pair on this route + const segmentOverride = await this.prisma.segmentFareRule.findFirst({ + where: { + routeId: route.id, + seatClassId: dto.seatClassId, + originStopSequence: originStop.sequence, + destinationStopSequence: destStop.sequence, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + nationality: dto.nationality ?? null, + }, + }) ?? await this.prisma.segmentFareRule.findFirst({ + where: { + routeId: route.id, + seatClassId: dto.seatClassId, + originStopSequence: originStop.sequence, + destinationStopSequence: destStop.sequence, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + nationality: null, + }, + }); + + if (segmentOverride) { + // Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate + baseFarePerPassengerMinor = segmentOverride.baseFareMinor; + ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; + fareSource = 'SEGMENT_FARE_RULE'; + } else if (fareRule?.tripId) { + // Schedule-scoped flat override baseFarePerPassengerMinor = fareRule.baseFareMinor; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; - fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE'; + fareSource = 'SCHEDULE_FARE_RULE'; } else { - // Distance × rate fallback - ratePerKmMinor = seatClass.baseFareMinor; - baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; - fareSource = 'DISTANCE_RATE'; + // Default: distance-based using tariff formula: km × rate × 1.02 + // baseFareMinor stores the per-km rate (tariff decimal × 100000) + ratePerKmMinor = nationalitySeatClass.baseFareMinor; + baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02); + fareSource = 'SEAT_CLASS_BASE_FARE'; } // Premium and insurance fees applied per passenger @@ -110,8 +153,7 @@ export class FareEngineService { } const afterDiscountMinor = subtotalMinor - discountMinor; - const taxMinor = Math.round(afterDiscountMinor * TAX_RATE); - const totalEtbMinor = afterDiscountMinor + taxMinor; + const totalEtbMinor = afterDiscountMinor; const billingCurrency = resolveCurrencyFromNationality(dto.nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); @@ -119,8 +161,9 @@ export class FareEngineService { const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, - `Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`, - `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`, + `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`, + `Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`, + `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`, `Premium/pax: ${premiumPerPassenger} ETB minor`, `Insurance/pax: ${insurancePerPassenger} ETB minor`, `Total fare/pax: ${farePerPassengerMinor} ETB minor`, @@ -132,10 +175,8 @@ export class FareEngineService { ``, `Subtotal: ${subtotalMinor} ETB minor`, `Discount: ${promoLabel} → -${discountMinor} ETB minor`, - `Tax (5%): +${taxMinor} ETB minor`, `Total (ETB): ${totalEtbMinor} ETB minor`, ``, - `Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`, `Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`, `Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`, `Fare source: ${fareSource}`, @@ -146,7 +187,8 @@ export class FareEngineService { routeCode: route.code, originName: originStation?.name ?? dto.originStationId, destinationName: destStation?.name ?? dto.destinationStationId, - seatClassName: seatClass.name, + seatClassId: nationalitySeatClass.id, + seatClassName: nationalitySeatClass.name, totalDistanceKm, ratePerKmMinor, baseFarePerPassengerMinor, @@ -159,7 +201,6 @@ export class FareEngineService { paidChildrenCount, subtotalMinor, discountMinor, - taxMinor, totalMinor: totalEtbMinor, billingCurrency, totalInBillingCurrency, @@ -284,16 +325,13 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); return fareRules.map(rule => { const seatClassId = rule.seatClassId; - const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE); - const totalMinor = rule.baseFareMinor + taxMinor; return { seatClassId, seatClassName: 'Unknown', baseFareMinor: rule.baseFareMinor, - taxMinor, - totalMinor, + totalMinor: rule.baseFareMinor, billingCurrency, - totalInBillingCurrency: Math.round(totalMinor * exchangeRate), + totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), exchangeRate, source: 'FARE_RULE', }; diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 2f2a76f48..f7527020b 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -79,11 +79,10 @@ export class UpdateClassDto { @ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string; @ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional() @IsOptional() @IsString() description?: string; - @ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number; - @ApiPropertyOptional({ example: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; + @ApiPropertyOptional({ example: 50 }) @IsOptional() @IsInt() baseFareMinor?: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number; + @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean; } export class GenerateSeatMapDto { diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index ebec606c6..a725b9cd4 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -258,6 +258,8 @@ export class FleetService { name: dto.name, description: dto.description, baseFareMinor: dto.baseFareMinor, + premiumMinor: dto.premiumMinor, + insuranceFeeMinor: dto.insuranceFeeMinor, }; if (dto.isActive !== undefined) { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index 72cbbbb47..1cd382862 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { RoutesService } from './routes.service'; -import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto'; +import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Routes') @@ -93,4 +93,35 @@ Route stops carry distanceKm for fare-by-distance calculations.`, @ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' }) @ApiResponse({ status: 404, description: 'Route not found' }) getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); } + + // ── Route Coach Template ─────────────────────────────────────────────────── + + @Get(':id/coaches') + @ApiOperation({ summary: 'Get the default coach lineup for this route' }) + @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'Ordered coach template with coach and coach type details' }) + @ApiResponse({ status: 404, description: 'Route not found' }) + getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); } + + @Put(':id/coaches') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Set the default coach lineup for this route', + description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.', + }) + @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'Updated coach template' }) + @ApiResponse({ status: 400, description: 'Duplicate positions or inactive coach' }) + @ApiResponse({ status: 404, description: 'Route or coach not found' }) + setCoachTemplate(@Param('id') id: string, @Body() dto: SetRouteCoachTemplateDto) { + return this.service.setRouteCoachTemplate(id, dto); + } + + @Delete(':id/coaches') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Clear the default coach lineup for this route' }) + @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiResponse({ status: 200, description: 'Template cleared' }) + @ApiResponse({ status: 404, description: 'Route not found' }) + clearCoachTemplate(@Param('id') id: string) { return this.service.removeRouteCoachTemplate(id); } } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index bce25fea5..bcd4dfcee 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -44,3 +44,14 @@ export class UpdateRouteDto { @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } + +export class RouteCoachTemplateItemDto { + @ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string; + @ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() @Min(1) positionNumber: number; +} + +export class SetRouteCoachTemplateDto { + @ApiProperty({ type: [RouteCoachTemplateItemDto], description: 'Ordered list of coaches for this route. Replaces the existing template.' }) + @IsArray() @ValidateNested({ each: true }) @Type(() => RouteCoachTemplateItemDto) + coaches: RouteCoachTemplateItemDto[]; +} diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 2a7254f37..479f6b0ca 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto'; +import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @Injectable() @@ -202,6 +202,44 @@ export class RoutesService { }); } + async getRouteCoachTemplate(routeId: string) { + const route = await this.prisma.route.findUnique({ where: { id: routeId } }); + if (!route) throw new NotFoundException('Route not found'); + return this.prisma.routeCoachTemplate.findMany({ + where: { routeId }, + include: { coach: { include: { coachType: true } } }, + orderBy: { positionNumber: 'asc' }, + }); + } + + async setRouteCoachTemplate(routeId: string, dto: SetRouteCoachTemplateDto) { + const route = await this.prisma.route.findUnique({ where: { id: routeId } }); + if (!route) throw new NotFoundException('Route not found'); + + const coachIds = dto.coaches.map(c => c.coachId); + const coaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } }); + if (coaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found'); + const inactive = coaches.find(c => c.status !== 'ACTIVE'); + if (inactive) throw new BadRequestException(`Coach ${inactive.number} is not active`); + + const positions = dto.coaches.map(c => c.positionNumber); + if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values'); + + await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); + await this.prisma.routeCoachTemplate.createMany({ + data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })), + }); + + return this.getRouteCoachTemplate(routeId); + } + + async removeRouteCoachTemplate(routeId: string) { + const route = await this.prisma.route.findUnique({ where: { id: routeId } }); + if (!route) throw new NotFoundException('Route not found'); + await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); + return { deleted: true, routeId }; + } + // ── Used by SchedulesService ─────────────────────────────────────────────── /** diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 6d1091719..1a5547d48 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; @@ -132,6 +132,23 @@ export class SchedulesController { @Body() dto: UpdateStopTimeDto, ) { return this.service.updateStop(id, sequence, dto); } + @Put(':scheduleId/fares/:seatClassId') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Override fare for a specific seat class on a schedule', + description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.', + }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiParam({ name: 'seatClassId', description: 'SeatClass UUID' }) + @ApiResponse({ status: 200, description: 'Fare rule upserted' }) + upsertScheduleFare( + @Param('scheduleId') scheduleId: string, + @Param('seatClassId') seatClassId: string, + @Body() dto: { baseFareMinor: number; validFrom?: string; validUntil?: string }, + ) { + return this.service.upsertScheduleFare(scheduleId, seatClassId, dto); + } + @Get(':scheduleId/fares/stored') @ApiOperation({ summary: 'Get stored fare rules for a schedule' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index b6363e085..044768e90 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -119,7 +119,7 @@ export class BulkCreateSchedulesDto { @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; - @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' }) + @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule. Overrides the route coach template if provided.' }) @IsOptional() @IsArray() @IsString({ each: true }) coachIds?: string[]; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 66e43d6e9..2d194fd7b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -46,6 +46,8 @@ export class SchedulesService { const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); + // createSchedule already auto-applies the route coach template; + // only override if explicit coachIds are provided if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, @@ -177,6 +179,18 @@ export class SchedulesService { const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); + // Auto-apply route coach template if one is defined + const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ + where: { routeId: dto.routeId }, + orderBy: { positionNumber: 'asc' }, + }); + if (coachTemplates.length > 0) { + await this.assignCoaches( + schedule.id, + coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + ); + } + return this.getSchedule(schedule.id); } @@ -402,6 +416,34 @@ export class SchedulesService { }); } + async upsertScheduleFare( + scheduleId: string, + seatClassId: string, + dto: { baseFareMinor: number; validFrom?: string; validUntil?: string }, + ) { + const [schedule, seatClass] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }), + this.prisma.seatClass.findUnique({ where: { id: seatClassId } }), + ]); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!seatClass) throw new NotFoundException('Seat class not found'); + + const now = new Date(); + const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now; + const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null; + + return this.prisma.$transaction(async (tx) => { + await tx.fareRule.updateMany({ + where: { tripId: scheduleId, seatClassId, validUntil: null }, + data: { validUntil: now }, + }); + return tx.fareRule.create({ + data: { tripId: scheduleId, seatClassId, baseFareMinor: dto.baseFareMinor, currency: 'ETB', validFrom, validUntil }, + include: { seatClass: true }, + }); + }); + } + createFareRule(dto: CreateFareRuleDto) { const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; return this.prisma.fareRule.create({ @@ -555,10 +597,10 @@ export class SchedulesService { await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); - const data = coaches.map((c, idx) => ({ + const data = coaches.map((c) => ({ scheduleId, coachId: c.coachId, - positionNumber: idx + 1, + positionNumber: c.positionNumber, isOperational: true, })); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 08687d291..6f3bc5a67 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -418,6 +418,7 @@ export class SearchService { }, }); if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId); @@ -426,56 +427,25 @@ export class SearchService { } const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } }); + if (!seatClass) throw new NotFoundException(`Seat class '${dto.seatClassName}' not found`); - const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; - const now = new Date(); - const nationality = dto.nationality; - - const candidates = await this.prisma.fareRule.findMany({ - where: { - seatClassId: seatClass?.id, - validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], - }, + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: seatClass.id, + nationality: dto.nationality, + scheduleId: dto.scheduleId, + adultCount: dto.adultCount, + childCount: dto.childCount ?? 0, + promoCode: dto.promoCode, }); - const bestMatch = this.selectBestFareRule( - candidates, - dto.scheduleId, - segmentRoute, - fullRoute, - nationality, - ); - - const baseFareMinor = bestMatch?.baseFareMinor - ?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName); - - const adultCount = dto.adultCount; - const childCount = dto.childCount ?? 0; - const adultFareMinor = baseFareMinor * adultCount; - const paidChildrenCount = Math.max(0, childCount - 1); - const childFareMinor = baseFareMinor * paidChildrenCount; - const totalBaseFareMinor = adultFareMinor + childFareMinor; - - let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); - if (promo?.active && promo.validUntil > now) { - discountMinor = promo.percentOff - ? Math.round(totalBaseFareMinor * promo.percentOff / 100) - : (promo.amountOffMinor ?? 0); - } - } - const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; - const taxesMinor = 0; - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); + const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor); - const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency); const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; @@ -487,13 +457,23 @@ export class SearchService { segmentRoute, seatClassName: dto.seatClassName, nationality: dto.nationality, - adultCount, childCount, - baseFareMinor, adultFareMinor, childFareMinor, - freeChildrenCount: Math.min(childCount, 1), - paidChildrenCount, totalBaseFareMinor, - discountMinor, loyaltyRedemptionMinor: loyaltyMinor, - taxesFeesMinor: taxesMinor, totalMinor, - currency: 'ETB', displayCurrency, displayTotalMinor, + adultCount: fare.adultCount, + childCount: fare.childCount, + baseFareMinor: fare.baseFarePerPassengerMinor, + adultFareMinor: fare.adultCount * fare.farePerPassengerMinor, + childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor, + freeChildrenCount: fare.freeChildrenCount, + paidChildrenCount: fare.paidChildrenCount, + premiumMinor: fare.premiumPerPassenger, + insuranceFeeMinor: fare.insurancePerPassenger, + totalBaseFareMinor: fare.subtotalMinor, + discountMinor: fare.discountMinor, + taxesFeesMinor: 0, + loyaltyRedemptionMinor: loyaltyMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, }; } @@ -505,15 +485,19 @@ export class SearchService { ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); - // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany - const seatClassMap = new Map(); + // Collect seat class IDs from the schedule include for the ID set, + // but fetch fresh records from DB so updated baseFareMinor is always current + const seatClassIdSet = new Set(); for (const a of schedule.coachAssignments) { for (const sc of (a.coach.coachType?.seatClasses ?? [])) { - if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc); + if (sc.isActive) seatClassIdSet.add(sc.id); } } - const seatClasses = Array.from(seatClassMap.values()) - .sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor); + const freshSeatClasses = await this.prisma.seatClass.findMany({ + where: { id: { in: Array.from(seatClassIdSet) }, isActive: true }, + }); + const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc])); + const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor); if (seatClasses.length === 0) return []; @@ -531,9 +515,9 @@ export class SearchService { }); return { seatClassName: fare.seatClassName, - baseFareMinor: fare.baseFarePerPassengerMinor, + baseFareMinor: fare.totalMinor, displayCurrency: fare.billingCurrency as Currency, - displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), + displayAmountMinor: fare.totalInBillingCurrency, }; } catch { return null; @@ -568,12 +552,16 @@ export class SearchService { if (fareRules.length > 0) { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); - return fareRules.map(rule => ({ - seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', - baseFareMinor: rule.baseFareMinor, - displayCurrency, - displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), - })); + const TAX_RATE = 0.05; + return fareRules.map(rule => { + const totalMinor = rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE); + return { + seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', + baseFareMinor: totalMinor, + displayCurrency, + displayAmountMinor: Math.round(totalMinor * exchangeRate), + }; + }); } } @@ -644,54 +632,4 @@ export class SearchService { }); } - private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise { - if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`); - const schedule = await this.prisma.trainSchedule.findUnique({ - where: { id: scheduleId }, - select: { routeId: true, originStationId: true, destinationStationId: true }, - }); - if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); - const fare = await this.fareEngine.calculate({ - routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, - seatClassId, - }); - return fare.baseFarePerPassengerMinor; - } - - private selectBestFareRule( - candidates: any[], - scheduleId: string, - segmentRoute: string, - fullRoute: string, - nationality?: string, - ): any | null { - const priorities = [ - { tripId: scheduleId, route: segmentRoute, nationality }, - { tripId: scheduleId, route: segmentRoute, nationality: null }, - { tripId: scheduleId, route: fullRoute, nationality }, - { tripId: scheduleId, route: fullRoute, nationality: null }, - { tripId: scheduleId, route: null, nationality }, - { tripId: scheduleId, route: null, nationality: null }, - { tripId: null, route: segmentRoute, nationality }, - { tripId: null, route: segmentRoute, nationality: null }, - { tripId: null, route: fullRoute, nationality }, - { tripId: null, route: fullRoute, nationality: null }, - { tripId: null, route: null, nationality }, - { tripId: null, route: null, nationality: null }, - ]; - - for (const priority of priorities) { - const match = candidates.find( - (c) => - c.tripId === priority.tripId && - c.route === priority.route && - c.nationality === priority.nationality, - ); - if (match) return match; - } - - return null; - } } diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 10fcfe0cf..79151bdc9 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -19,21 +19,31 @@ export class SeatClassesService { return sc; } + async updateSeatClass(id: string, dto: any) { + const sc = await this.prisma.seatClass.findUnique({ where: { id } }); + if (!sc) throw new NotFoundException('SeatClass not found'); + const { basePrice, ...rest } = dto; + const data = { + ...rest, + ...(basePrice !== undefined && { baseFareMinor: basePrice }), + }; + return this.prisma.seatClass.update({ where: { id }, data }); + } + async createSeatClass(dto: any) { try { - return await this.prisma.seatClass.create({ data: dto }); + const { basePrice, ...rest } = dto; + const data = { + ...rest, + ...(basePrice !== undefined && { baseFareMinor: basePrice }), + }; + return await this.prisma.seatClass.create({ data }); } catch (e: any) { if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`); throw e; } } - async updateSeatClass(id: string, dto: any) { - const sc = await this.prisma.seatClass.findUnique({ where: { id } }); - if (!sc) throw new NotFoundException('SeatClass not found'); - return this.prisma.seatClass.update({ where: { id }, data: dto }); - } - async deleteSeatClass(id: string) { const sc = await this.prisma.seatClass.findUnique({ where: { id } }); if (!sc) throw new NotFoundException('SeatClass not found'); diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index 9759ff297..fb8a29a6e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { CurrencyModule } from '../currency/currency.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule], providers: [TasksService], }) export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index bc52cc613..401a37ddb 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -2,12 +2,20 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; +import { CurrencyService } from '../currency/currency.service'; /** Maximum time (hours) a passenger has to pay after booking. */ const MAX_PAYMENT_HOURS = 2; /** Minutes before departure: cutoff for new bookings and payment deadline. */ const CUTOFF_MINUTES = 30; +// Retention windows +const OTP_RETENTION_HOURS = 1; +const FAYDA_SESSION_RETENTION_HOURS = 1; +const AUDIT_LOG_RETENTION_DAYS = 365; +const WEBHOOK_EVENT_RETENTION_DAYS = 90; +const GATE_LOG_RETENTION_DAYS = 180; + /** * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) */ @@ -32,6 +40,7 @@ export class TasksService { constructor( private readonly prisma: PrismaService, private readonly sms: SmsClientService, + private readonly currencyService: CurrencyService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -243,4 +252,47 @@ export class TasksService { this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`); } } + + // ───────────────────────────────────────────────────────────────────────── + // Daily at 01:00 EAT: fetch mid-market rates from central bank API. + // ───────────────────────────────────────────────────────────────────────── + @Cron('0 1 * * *', { timeZone: 'Africa/Addis_Ababa' }) + async syncExchangeRates() { + try { + await this.currencyService.syncExchangeRates(); + } catch (err) { + this.logger.error(`Exchange rate sync failed: ${(err as Error).message}`); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. + // ───────────────────────────────────────────────────────────────────────── + @Cron('0 2 * * *') + async purgeExpiredData() { + const now = new Date(); + + const otpCutoff = new Date(now.getTime() - OTP_RETENTION_HOURS * 60 * 60 * 1000); + const faydaCutoff = new Date(now.getTime() - FAYDA_SESSION_RETENTION_HOURS * 60 * 60 * 1000); + const auditCutoff = new Date(now.getTime() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); + const webhookCutoff = new Date(now.getTime() - WEBHOOK_EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1000); + const gateCutoff = new Date(now.getTime() - GATE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000); + + const [otps, faydaSessions, auditLogs, webhookEvents, gateLogs] = await Promise.all([ + this.prisma.otpCode.deleteMany({ + where: { OR: [{ expiresAt: { lte: otpCutoff } }, { verified: true, createdAt: { lte: otpCutoff } }] }, + }), + this.prisma.faydaVerificationSession.deleteMany({ + where: { OR: [{ expiresAt: { lte: faydaCutoff } }, { status: { in: ['COMPLETED', 'FAILED'] }, createdAt: { lte: faydaCutoff } }] }, + }), + this.prisma.auditLog.deleteMany({ where: { createdAt: { lte: auditCutoff } } }), + this.prisma.paymentWebhookEvent.deleteMany({ where: { receivedAt: { lte: webhookCutoff } } }), + this.prisma.gateValidationLog.deleteMany({ where: { validatedAt: { lte: gateCutoff } } }), + ]); + + this.logger.log( + `Data retention purge: ${otps.count} OTPs, ${faydaSessions.count} Fayda sessions, ` + + `${auditLogs.count} audit logs, ${webhookEvents.count} webhook events, ${gateLogs.count} gate logs deleted`, + ); + } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 797adbb55..e482189d2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -159,6 +159,8 @@ export class TicketsService { } : null, status: t.status, validatedAt: t.validatedAt, + boardedAt: t.validatedAt, + qrCode: t.qrPayload ?? null, createdAt: t.issuedAt, }; }), @@ -247,9 +249,12 @@ export class TicketsService { // Use first seat for primary data const primarySeat = passengerSeats[0]; - // Build passenger QR data with all legs included - const qrData = JSON.stringify({ + const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`; + + // Re-encode QR with ticketNumber included + const qrDataWithTicket = JSON.stringify({ ref: booking.bookingRef, + ticketNumber: barcodePayload, type: booking.bookingType, passenger: passengerName, seats: passengerSeats.map(ps => ({ @@ -259,8 +264,7 @@ export class TicketsService { scheduleId: ps.scheduleId || booking.scheduleId, })), }); - const qrPayload = await QRCode.toDataURL(qrData); - const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`; + const qrPayloadFinal = await QRCode.toDataURL(qrDataWithTicket); const ticket = await this.prisma.ticket.create({ data: { @@ -270,7 +274,7 @@ export class TicketsService { seatId: primarySeat.seatId, leg: primarySeat.leg || 1, scheduleId: primarySeat.scheduleId || booking.scheduleId, - qrPayload, + qrPayload: qrPayloadFinal, barcodePayload, } as any, }); @@ -371,15 +375,13 @@ export class TicketsService { let bookingRef = qrCodeOrRef; try { const qrData = JSON.parse(qrCodeOrRef); - if (qrData.ref) { - bookingRef = qrData.ref; - } + if (qrData.ref) bookingRef = qrData.ref; } catch { - // Not JSON, treat as booking reference + // Not JSON, treat as booking reference or ticket number } // Get booking and ticket info - const booking = await this.prisma.booking.findUnique({ + let booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, @@ -389,6 +391,23 @@ export class TicketsService { }, }); + if (!booking) { + // Input may be a ticket number (barcodePayload) — look it up + const ticket = await this.prisma.ticket.findFirst({ where: { barcodePayload: bookingRef } }); + if (ticket) { + booking = await this.prisma.booking.findUnique({ + where: { bookingRef: ticket.bookingRef }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + tickets: true, + seats: { include: { seat: { include: { coach: true } } } }, + }, + }); + if (booking) bookingRef = (booking as any).bookingRef; + } + } + if (!booking) { throw new NotFoundException('Ticket not found'); } @@ -443,6 +462,7 @@ export class TicketsService { message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`, boarding: { ticketId: ticket.id, + ticketNumber: ticket.barcodePayload, bookingRef: booking.bookingRef, passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A', route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`, diff --git a/apps/edr-passenger-web/backoffice/next.config.js b/apps/edr-passenger-web/backoffice/next.config.js index 5690be409..ba1deb613 100644 --- a/apps/edr-passenger-web/backoffice/next.config.js +++ b/apps/edr-passenger-web/backoffice/next.config.js @@ -1,12 +1,13 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', reactStrictMode: true, transpilePackages: ['@edr/types', '@edr/ui-common'], env: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000', }, images: { - unoptimized: true, + unoptimized: false, }, }; diff --git a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx index db5f362dc..75f85cc90 100644 --- a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx @@ -15,7 +15,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro const canvasRef = useRef(null); const [isScanning, setIsScanning] = useState(false); const [isInitializing, setIsInitializing] = useState(false); - const [stream, setStream] = useState(null); + const streamRef = useRef(null); const [cameraError, setCameraError] = useState(null); const scanIntervalRef = useRef(null); @@ -34,9 +34,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro } // First, stop any existing stream - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; } // Request camera access with simpler fallback @@ -120,7 +120,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro } // Set state to show video - setStream(mediaStream); + streamRef.current = mediaStream; setIsScanning(true); setIsInitializing(false); @@ -144,9 +144,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro onError(errorMsg); // Clean up on error - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; } setIsScanning(false); setIsInitializing(false); @@ -158,16 +158,16 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro clearInterval(scanIntervalRef.current); scanIntervalRef.current = null; } - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; } if (videoRef.current) { videoRef.current.srcObject = null; } setIsScanning(false); setCameraError(null); - }, [stream]); + }, []); // QR code scanning with jsqr const scanFrame = useCallback(() => { @@ -201,15 +201,19 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro useEffect(() => { if (isScanning) { - scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms - } - return () => { + scanIntervalRef.current = window.setInterval(scanFrame, 100); + } else { if (scanIntervalRef.current) { clearInterval(scanIntervalRef.current); + scanIntervalRef.current = null; } - stopCamera(); - }; - }, [isScanning, scanFrame, stopCamera]); + } + }, [isScanning, scanFrame]); + + // Cleanup on unmount only + useEffect(() => { + return () => stopCamera(); + }, [stopCamera]); // Load jsqr from CDN useEffect(() => { @@ -294,9 +298,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro