From 2b4dfc64906c098e4ed6f125f8b300819816b550 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 22:54:32 +0000 Subject: [PATCH 1/2] feat: implement staff price adjustment feature for bookings --- .../1820000000003-AddPriceAdjustment.ts | 39 ++++ .../bookings/booking-pricing.service.ts | 3 +- .../bookings/booking-transition.service.ts | 24 +++ .../modules/bookings/bookings.controller.ts | 20 +++ .../bookings/dto/request-changes.dto.ts | 18 +- .../bookings/entities/booking.entity.ts | 16 ++ .../rule-engine/rule-engine.service.ts | 30 +++- .../bookings/BookingPricingSummary.tsx | 167 +++++++++++++++--- .../src/services/bookings.service.ts | 7 + .../backoffice/src/types/booking.ts | 14 ++ .../BookingDetailPage/components/pricing.tsx | 52 +++--- .../src/pages/bookings/NewBookingPage.tsx | 58 +++--- packages/types/src/freight/index.ts | 5 + 13 files changed, 364 insertions(+), 89 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts diff --git a/apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts b/apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts new file mode 100644 index 000000000..7ad97e7bf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Staff price adjustment: an optional override of a booking's computed total, + * with who/when/why. When set, the customer sees the adjusted total + a badge. + */ +export class AddPriceAdjustment1820000000003 implements MigrationInterface { + name = 'AddPriceAdjustment1820000000003'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_total_amount numeric(14,2);`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_by_staff_id uuid;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_at timestamptz;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjustment_reason text;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjustment_reason;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_by_staff_id;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_total_amount;`, + ); + } +} 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 c06981cce..8502aa7ad 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 @@ -252,7 +252,8 @@ export class BookingPricingService { serviceTypeId: booking.serviceTypeId, paymentCurrency: booking.paymentCurrency, tradeDirection: booking.tradeDirection, - isHazardous: booking.isHazardous, + // Coerce defensively in case the stored flag is a string ("true"/"false"). + isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true', isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, 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 7adbc56f4..82b13c4bc 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 @@ -451,6 +451,30 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } + /** + * Staff adjusts a booking's total price. Stores an override (with who/when/why) + * that supersedes the computed total for the customer, who sees an + * "Adjusted by EDR" badge. Passing null clears the adjustment. + */ + async adjustPrice( + bookingId: string, + amount: number | null, + staffId: string, + reason?: string, + ): Promise { + await this.bookingsService.findById(bookingId); + if (amount != null && amount < 0) { + throw new BadRequestException('Adjusted amount cannot be negative'); + } + await this.bookingsRepository.update(bookingId, { + adjustedTotalAmount: amount, + adjustedByStaffId: amount == null ? null : staffId, + adjustedAt: amount == null ? null : new Date(), + adjustmentReason: amount == null ? null : (reason ?? null), + } as never); + return this.bookingsService.findById(bookingId); + } + // ── Document clearance gate (post counter-sign) ─────────────────────────── /** diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 8c0268154..d650098f9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -42,6 +42,7 @@ import { FilterBookingDto } from './dto/filter-booking.dto'; import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { + AdjustPriceDto, ApproveStepDto, CancelBookingDto, RejectBookingDto, @@ -455,6 +456,25 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/adjust-price') + @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) + @ApiOperation({ + summary: 'Staff adjust booking total price (override; null clears it)', + }) + async adjustPrice( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AdjustPriceDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.adjustPrice( + id, + dto.amount ?? null, + resolveAuthUserId(user), + dto.reason, + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/government-expedite') @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 6e6a52b03..d598b948e 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsString, Min, MinLength } from 'class-validator'; export class RequestChangesDto { @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) @@ -44,6 +44,22 @@ export class RejectBookingDto { reason?: string; } +export class AdjustPriceDto { + @ApiPropertyOptional({ + description: + 'New total price. Omit or send null to clear a previous adjustment.', + }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number | null; + + @ApiPropertyOptional({ description: 'Reason for the adjustment' }) + @IsOptional() + @IsString() + reason?: string; +} + export class ReviewDocumentDto { @ApiProperty({ description: 'The document fileKey being reviewed' }) @IsString() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 3ec08eee2..9586e4bb8 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -161,6 +161,22 @@ export class Booking extends BaseEntity { @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) totalAmount!: number; + /** + * Staff-adjusted total price. When set, it overrides the computed totalAmount + * for the customer, who is shown an "Adjusted by EDR" badge. + */ + @Column({ name: 'adjusted_total_amount', type: 'numeric', precision: 14, scale: 2, nullable: true }) + adjustedTotalAmount?: number | null; + + @Column({ name: 'adjusted_by_staff_id', type: 'uuid', nullable: true }) + adjustedByStaffId?: string | null; + + @Column({ name: 'adjusted_at', type: 'timestamptz', nullable: true }) + adjustedAt?: Date | null; + + @Column({ name: 'adjustment_reason', type: 'text', nullable: true }) + adjustmentReason?: string | null; + @Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' }) paymentStatus!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 35dbef868..9884ee854 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, BadRequestException } from '@nestjs/common'; +import { Inject, Injectable, Logger, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; @@ -89,6 +89,8 @@ export interface RuleEvaluationResult { @Injectable() export class RuleEngineService { + private readonly logger = new Logger(RuleEngineService.name); + constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepo: ICargoTypesRepository, @@ -207,6 +209,14 @@ export class RuleEngineService { const liveRates = await this.ratesRepo.findLiveRates(); const rateById = new Map(liveRates.map((r) => [r.id, r])); + // TEMP diagnostic — trace the surcharge trigger state so we can confirm + // whether a "Hazardous" line is firing for a non-hazardous booking. + this.logger.debug( + `surcharge eval: isHazardous=${input.isHazardous} (type ${typeof input.isHazardous}) ` + + `hasReefer=${hasReefer} hasOverweight=${hasOverweight} ` + + `shippingLineMapped=${shippingLineMapped}`, + ); + for (const st of surchargeTypes) { const triggered = this.matchesTrigger(st.triggerCondition, { isHazardous: input.isHazardous, @@ -233,6 +243,11 @@ export class RuleEngineService { } } + // Safety guard: never include a surcharge with a non-positive amount (a + // zero-rate or zero-trigger line would otherwise show as a confusing + // "free" surcharge on the breakdown). + if (!(calculatedAmount > 0)) continue; + appliedModifiers.push({ surchargeTypeId: st.id, surchargeTypeCode: st.code, @@ -382,17 +397,20 @@ export class RuleEngineService { allowConsolidation: boolean; }, ): boolean { + // Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. + // from multipart form-data) and a non-empty "false" string is truthy. + const truthy = (v: unknown): boolean => v === true || v === 'true'; switch (condition) { case 'CARGO_FLAG_HAZARDOUS': - return state.isHazardous; + return truthy(state.isHazardous); case 'CARGO_FLAG_REEFER': - return state.hasReefer; + return truthy(state.hasReefer); case 'VGM_EXCEEDS_LIMIT': - return state.hasOverweight; + return truthy(state.hasOverweight); case 'SHIPPING_LINE_MAPPED': - return state.shippingLineMapped; + return truthy(state.shippingLineMapped); case 'CONSOLIDATION_ENABLED': - return state.allowConsolidation; + return truthy(state.allowConsolidation); default: return false; } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx index 1342df085..c0dd56f74 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx @@ -1,50 +1,171 @@ -import { Banknote, Receipt } from "lucide-react"; -import { Paper, Stack, Group, Text, Divider } from "@mantine/core"; +import { useState } from "react"; +import { Banknote, Pencil, Receipt } from "lucide-react"; +import { + Button, + Divider, + Group, + NumberInput, + Paper, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import type { BookingDetail } from "@/types/booking"; +import { bookingsService } from "@/services/bookings.service"; import { SectionCard } from "./detail/SectionCard"; import { detailStyles } from "./detail/booking-detail.styles"; export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { - const amount = Number(booking.totalAmount); - const modifiers = booking.cargoModifiers ?? []; + const qc = useQueryClient(); + const computed = Number(booking.totalAmount); + const isAdjusted = + booking.adjustedTotalAmount !== null && + booking.adjustedTotalAmount !== undefined; + const effective = isAdjusted ? Number(booking.adjustedTotalAmount) : computed; + + const lineItems = booking.pricingBreakdown?.lineItems ?? []; + + const [editing, setEditing] = useState(false); + const [amount, setAmount] = useState(effective); + const [reason, setReason] = useState(""); + + const adjustMutation = useMutation({ + mutationFn: (payload: { amount: number | null; reason?: string }) => + bookingsService.adjustPrice(booking.id, payload.amount, payload.reason), + onSuccess: () => { + toast.success("Price updated"); + setEditing(false); + qc.invalidateQueries({ queryKey: ["bookings"] }); + }, + onError: () => toast.error("Could not update price"), + }); + + const fmt = (n: number) => + `${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`; return ( - - Total amount - - - {booking.paymentCurrency}{" "} - {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} - + +
+ + {isAdjusted ? "Adjusted total" : "Total amount"} + + + {fmt(effective)} + + {isAdjusted && ( + + Computed: {fmt(computed)} + {booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""} + + )} +
+ {!editing && ( + + )} +
+ + {editing && ( + + setAmount(v === "" ? "" : Number(v))} + min={0} + radius="md" + prefix={`${booking.paymentCurrency} `} + thousandSeparator="," + /> +