diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts index 56be0c545..0fa7457a8 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -1,13 +1,19 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsNumber, Min } from 'class-validator'; +import { IsNumber, IsOptional, Min } from 'class-validator'; export class ApproveLastMileRequestDto { - // The approve dialog prefills this from GET :id/price-estimate (rule-based), - // but the chief can still override — the typed value is what's invoiced. - @ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) - @Transform(({ value }) => Number(value)) + // Omitted = the rule-based last-mile rate estimate is the advance. The chief + // can still override with an explicit amount (required when no rate covers + // the job). + @ApiPropertyOptional({ + description: + 'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).', + example: 3000, + }) + @IsOptional() + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) @IsNumber() @Min(0.01) - advanceAmount!: number; + advanceAmount?: number; } diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 10f82ad07..c6910c7a7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -114,7 +114,7 @@ export class LastMileRequestsController { @Post(':id/approve') @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) - @ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' }) + @ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' }) approve( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveLastMileRequestDto, diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 901e7b9b5..b89f06479 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -299,7 +299,11 @@ export class LastMileRequestsService { return this.findById(id); } - async approve(id: string, staffId: string | null, advanceAmount: number): Promise { + async approve( + id: string, + staffId: string | null, + advanceOverride?: number | null, + ): Promise { const request = await this.findById(id); if (request.status !== LastMileRequestStatus.Submitted) { throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`); @@ -307,6 +311,18 @@ export class LastMileRequestsService { const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + // The live last-mile rates are the authority on the advance (km × rate); + // the chief's typed amount is only an override — and the only path when no + // rate covers the job. Snapshotted so the contract and invoice stay immune + // to later rate edits. + const estimate = await this.priceEstimate(id); + const advanceAmount = advanceOverride ?? estimate.total; + if (!advanceAmount || advanceAmount <= 0) { + throw new BadRequestException( + 'No live last-mile rate covers this job — enter the advance amount manually.', + ); + } + // Idempotent per booking — reuses the record if one already exists. const lastMile = await this.lastMileService.create({ bookingId: request.bookingId, @@ -316,10 +332,6 @@ export class LastMileRequestsService { // No invoice yet: the advance is invoiced by LastMileContractService.sign() // once the customer has signed the LM contract — doc first, then payment. - // Snapshot the rate estimate now so the contract shows the numbers the - // chief actually approved against, immune to later rate edits. - const estimate = await this.priceEstimate(id); - await this.requestsRepository.update(id, { status: LastMileRequestStatus.Approved, reviewedByStaffId: staffId, @@ -364,7 +376,10 @@ export class LastMileRequestsService { type: 'LAST_MILE_ADVANCE', companyId: booking.companyId, companyProfileId: booking.companyProfileId || '', - currency: booking.paymentCurrency || 'ETB', + // The advance is priced by the last-mile rate, so it bills in that + // rate's currency (birr for domestic trucking) — the booking's payment + // currency is only the fallback when the amount was a manual override. + currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB', lines: [ { chargeType: 'LAST_MILE_ADVANCE', diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index 64653abe3..47e80e7e2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -40,6 +40,11 @@ export function Step2ServiceType({ serviceType ?? {}; const firstMileEnabled = form.watch("firstMile.enabled"); const lastMileEnabled = form.watch("lastMile.enabled"); + // The paid-mile service prices the road legs into the booking itself; every + // other service defers last-mile billing to after the Djibouti departure + // (confirm containers → sign supplementary LM contract → pay advance). + const deferredLastMileBilling = + serviceType?.code !== "RAIL_CONTAINER_PAID_MILE"; const prevServiceType = useRef(serviceType); // Only clear a mile when the current service doesn't include it — this ran @@ -205,7 +210,11 @@ export function Step2ServiceType({ } title="Last Mile — Delivery" - description="Truck delivery from the destination rail yard to the final address (Port to Door)." + description={ + deferredLastMileBilling + ? "Truck delivery from the destination rail yard to the final address (Port to Door). Nothing is paid now — last-mile billing starts after your train departs Djibouti." + : "Truck delivery from the destination rail yard to the final address (Port to Door)." + } checked={field.value ?? false} onChange={(value) => { field.onChange(value); @@ -224,6 +233,15 @@ export function Step2ServiceType({ > {lastMileEnabled && ( + {deferredLastMileBilling && ( + + How it works: when your train departs Djibouti, you + will be asked to confirm which containers EDR should + deliver. Once truck availability is approved, you will + sign a short supplementary last-mile contract and pay + the delivery advance — nothing is charged at booking. + + )} { @@ -471,7 +476,11 @@ export function Step2ServiceType({ } title="Last Mile — Delivery" - description="Truck delivery from the destination rail yard to the final address (Port to Door)." + description={ + deferredLastMileBilling + ? "Truck delivery from the destination rail yard to the final address (Port to Door). Nothing is paid now — last-mile billing starts after your train departs Djibouti." + : "Truck delivery from the destination rail yard to the final address (Port to Door)." + } checked={field.value ?? false} onChange={(value) => { field.onChange(value); @@ -492,6 +501,15 @@ export function Step2ServiceType({ > {lastMileEnabled && ( + {deferredLastMileBilling && ( + + How it works: when your train departs Djibouti, you + will be asked to confirm which containers EDR should + deliver. Once truck availability is approved, you will + sign a short supplementary last-mile contract and pay + the delivery advance — nothing is charged at booking. + + )}