diff --git a/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts new file mode 100644 index 000000000..261e16099 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add paid column to first_mile and last_mile tables to track invoice payment status. + */ +export class AddPaidToFirstAndLastMile1860000000000 + implements MigrationInterface +{ + name = "AddPaidToFirstAndLastMile1860000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + DROP COLUMN IF EXISTS paid; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + DROP COLUMN IF EXISTS paid; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts index e2535083e..45e9f5b1a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity'; @@ -58,4 +58,9 @@ export class CreateFirstMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 253d2d4c8..27dcfef87 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -35,6 +35,9 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 08cd9ab10..ae0ada831 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -12,6 +12,8 @@ import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileRepository } from './first-mile.repository'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InvoiceEventPayload } from '../billing/billing.service'; type FirstMileListFilter = { status?: FirstMileStatus; @@ -146,6 +148,18 @@ export class FirstMileService { }; } + @OnEvent("firstmile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + await this.firstMileRepository.update(payload.sourceId, { paid: true } as any); + this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + } catch (err) { + this.logger.error( + `Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`, + ); + } + } + async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { relations: { @@ -175,6 +189,7 @@ export class FirstMileService { estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); } @@ -207,6 +222,7 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), @@ -215,7 +231,8 @@ export class FirstMileService { ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + } as any); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts index 4f6f5fc8f..08de632f1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity'; @@ -58,4 +58,9 @@ export class CreateLastMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 1747e308c..85d01b7f0 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -35,6 +35,9 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 7e3ddcb08..88a96e6c2 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -88,17 +88,17 @@ export class LastMileController { const booking = await this.bookingsService.findById(record.bookingId); if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { await this.billingService.generateInvoice({ - source: Freight.InvoiceSource.FirstMile, + source: Freight.InvoiceSource.LastMile, sourceId: record.id, - type: "FIRST_MILE", + type: "LAST_MILE", companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency: "ETB", lines: [ { - chargeType: "FIRST_MILE", - description: "First Mile Transportation Service", + chargeType: "LAST_MILE", + description: "Last Mile Transportation Service", quantity: 1, unitRate: record.remainingPayment, amount: record.remainingPayment, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 5faad49b9..732b1a618 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -10,6 +10,8 @@ import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileRepository } from './last-mile.repository'; +import { InvoiceEventPayload } from '../billing/billing.service'; +import { OnEvent } from '@nestjs/event-emitter'; type LastMileListFilter = { status?: LastMileStatus; @@ -137,12 +139,26 @@ export class LastMileService { estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); } + @OnEvent("lastmile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + await this.lastMileRepository.update(payload.sourceId, { paid: true } as any); + this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + } catch (err) { + this.logger.error( + `Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`, + ); + } + } + async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), @@ -151,7 +167,8 @@ export class LastMileService { ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + } as any); if (!updated) { throw new NotFoundException(`Last-mile record ${id} not found`); diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 52b3d7139..35618fa3b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -771,9 +771,25 @@ const FirstMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => { const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; + const isPaid = (row.original as any).paid; if (!hasDistance) { return ; } + if (isPaid) { + return ( + + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + #345 + + Paid + + ); + } return ( openInvoice(row.original)} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index a40721a1c..70d9e9105 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -751,9 +751,25 @@ const LastMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => { const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; + const isPaid = (row.original as any).paid; if (!hasDistance) { return ; } + if (isPaid) { + return ( + + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + #345 + + Paid + + ); + } return ( openInvoice(row.original)} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 24128d23d..1ad5d2bfb 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -149,7 +149,8 @@ export enum InvoiceSource { Booking = "booking", Warehouse = "warehouse", Demurrage = "demurrage", - FirstMile = "firstmile" + FirstMile = "firstmile", + LastMile = "lastmile" } export enum SchedulingStatus {