From 0ceb5957148bbf23c85c8affc8c6df6ba6f660bb Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 20 Aug 2026 12:35:19 +0000 Subject: [PATCH 1/3] feat(bookings): add ad-hoc additional-charge module New freight.additional_charge table, independent of BookingClearanceCharge (unbounded per booking, free-text reason). Draft -> send issues an invoice and notifies the customer in-app/SMS/email; settles via the standard .invoice.paid event. Adds the Additional charges row-menu entry next to Cancel booking, permission-gated. Not included: the Additional Payments tab UI, add-charge modal, portal pay flow. --- .../3620000000000-AdditionalCharge.ts | 43 +++ .../src/modules/billing/billing.service.ts | 9 + .../bookings/additional-charge.repository.ts | 13 + .../bookings/additional-charge.service.ts | 281 ++++++++++++++++++ .../modules/bookings/bookings.controller.ts | 58 ++++ .../src/modules/bookings/bookings.module.ts | 6 + .../bookings/dto/additional-charge.dto.ts | 35 +++ .../entities/additional-charge.entity.ts | 74 +++++ .../src/seed/freight-permissions.registry.ts | 41 +++ .../bookings/BookingActionsMenu.tsx | 22 +- .../backoffice/src/lib/permissions.ts | 6 + packages/types/src/freight/index.ts | 35 +++ 12 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts diff --git a/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts new file mode 100644 index 000000000..2525c1b3d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */ +export class AdditionalCharge3620000000000 implements MigrationInterface { + name = 'AdditionalCharge3620000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "freight"."additional_charge" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "booking_id" uuid NOT NULL, + "reason" text NOT NULL, + "status" character varying(20) NOT NULL DEFAULT 'DRAFT', + "amount" numeric(14,2) NOT NULL, + "currency" character varying(8) NOT NULL, + "file_record_id" uuid, + "invoice_id" uuid, + "payment_reference" character varying(64), + "created_by_staff_id" uuid, + "sent_by_staff_id" uuid, + "sent_at" timestamptz, + "paid_at" timestamptz, + "cancelled_by_staff_id" uuid, + "cancelled_at" timestamptz, + "cancel_reason" text, + CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"), + CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id") + REFERENCES "freight"."bookings"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking" + ON "freight"."additional_charge" ("booking_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`); + } +} 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 ac1c2ef24..247a1dad5 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -13,6 +13,7 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { AdditionalCharge } from "../bookings/entities/additional-charge.entity"; // Entity-only import (no module edge): portal reads resolve shipping-line // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; @@ -1958,6 +1959,14 @@ export class BillingService { .getRepository(Booking) .update({ id: invoice.sourceId }, { pnrCode: billReference }); } + // Same reference, for an ad-hoc additional charge — its own column, since + // an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking + // can carry many of these at once. + if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) { + await this.dataSource + .getRepository(AdditionalCharge) + .update({ id: invoice.sourceId }, { paymentReference: billReference }); + } // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept for local demos only. diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts new file mode 100644 index 000000000..9a7e652b0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { AdditionalCharge } from './entities/additional-charge.entity'; + +@Injectable() +export class AdditionalChargeRepository extends BaseRepository { + constructor(@InjectRepository(AdditionalCharge) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts new file mode 100644 index 000000000..bb5836d1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -0,0 +1,281 @@ +import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource, EntityManager } from 'typeorm'; +import { Freight, NotificationAudience, NotificationType } from '@edr/types'; + +import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FilesService } from '../files/files.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsService } from './bookings.service'; +import { BookingsRepository } from './bookings.repository'; +import { AdditionalChargeRepository } from './additional-charge.repository'; +import { AdditionalCharge } from './entities/additional-charge.entity'; +import { CreateAdditionalChargeDto } from './dto/additional-charge.dto'; + +const FILE_RESOURCE = 'additional_charges'; + +/** + * Ad-hoc extra charges finance raises against a booking, independent of + * `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS + * row per booking). Any number per booking, free-text reason. DRAFT until + * sent; sending issues the payable invoice and notifies the customer + * (in-app + SMS + email). Settles via `additional_charge.invoice.paid`, + * same event-driven pattern as every other invoice source. + */ +@Injectable() +export class AdditionalChargeService { + private readonly logger = new Logger(AdditionalChargeService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly repository: AdditionalChargeRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + private readonly billing: BillingService, + private readonly bookingsService: BookingsService, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private async findOwned(bookingId: string, chargeId: string): Promise { + const charge = await this.repository.findById(chargeId); + if (!charge || charge.bookingId !== bookingId) { + throw new NotFoundException('Additional charge not found'); + } + return charge; + } + + async list(bookingId: string): Promise { + const rows = await this.repository.findAll({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + return this.toDtoList(rows); + } + + async create( + bookingId: string, + dto: CreateAdditionalChargeDto, + staffId: string, + file?: Express.Multer.File, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + const shouldSend = dto.action === 'send'; + + const chargeId = await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(AdditionalCharge); + let saved = await repo.save( + repo.create({ + bookingId, + reason: dto.reason.trim(), + amount: dto.amount.toFixed(2), + currency: dto.currency.trim().toUpperCase(), + status: 'DRAFT', + createdByStaffId: staffId, + }), + ); + + if (file) { + const record = await this.filesService.upload({ + resourceId: saved.id, + resource: FILE_RESOURCE, + code: FILE_RESOURCE, + file, + uploadedByUserId: staffId, + }); + await repo.update(saved.id, { fileRecordId: record.id }); + } + + if (shouldSend) { + saved = await this.issueInvoice(manager, saved.id, booking, staffId); + } + return saved.id; + }); + + if (shouldSend) await this.notifyCustomerSent(chargeId); + return this.list(bookingId); + } + + async send(bookingId: string, chargeId: string, staffId: string): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT') { + throw new ConflictException('Only a draft charge can be sent.'); + } + const booking = await this.bookingsService.findById(bookingId); + + await this.dataSource.transaction((manager) => + this.issueInvoice(manager, charge.id, booking, staffId), + ); + await this.notifyCustomerSent(charge.id); + return this.list(bookingId); + } + + /** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */ + private async issueInvoice( + manager: EntityManager, + chargeId: string, + booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null }, + staffId: string, + ): Promise { + const repo = manager.getRepository(AdditionalCharge); + const charge = await repo.findOneByOrFail({ id: chargeId }); + + const invoice = await this.billing.generateInvoice( + { + source: Freight.InvoiceSource.AdditionalCharge, + sourceId: charge.id, + type: 'ADDITIONAL_CHARGE', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: charge.currency, + lines: [ + { + chargeType: 'ADDITIONAL_CHARGE', + description: `${charge.reason} — ${booking.reference ?? booking.id}`, + amount: Number(charge.amount), + }, + ], + }, + manager, + ); + + await repo.update(charge.id, { + status: 'SENT', + invoiceId: invoice.id, + sentByStaffId: staffId, + sentAt: new Date(), + }); + this.logger.log( + `Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`, + ); + return repo.findOneByOrFail({ id: charge.id }); + } + + private async notifyCustomerSent(chargeId: string): Promise { + try { + const charge = await this.repository.findById(chargeId); + if (!charge) return; + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'New charge on your booking', + body, + link: `/bookings/${charge.bookingId}`, + data: { + bookingId: charge.bookingId, + chargeId: charge.id, + amount: Number(charge.amount), + currency: charge.currency, + }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`); + } + } + + async cancel( + bookingId: string, + chargeId: string, + staffId: string, + reason?: string, + ): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT' && charge.status !== 'SENT') { + throw new ConflictException('Only a draft or unpaid charge can be cancelled.'); + } + if (charge.status === 'SENT' && charge.invoiceId) { + await this.billing.cancelInvoice(charge.invoiceId); + } + await this.repository.update(charge.id, { + status: 'CANCELLED', + cancelledByStaffId: staffId, + cancelledAt: new Date(), + cancelReason: reason ?? null, + }); + return this.list(bookingId); + } + + /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ + @OnEvent('additional_charge.invoice.paid') + async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise { + const charge = await this.repository.findById(payload.sourceId); + if (!charge || charge.status === 'PAID') return; + await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() }); + + try { + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Charge payment received', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + await this.inbox.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Additional charge paid', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + } catch (err) { + this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`); + } + } + + private async toDtoList(rows: AdditionalCharge[]): Promise { + if (!rows.length) return []; + + const filesByCharge = await this.filesService.findByResourceIdsGrouped( + rows.map((r) => r.id), + FILE_RESOURCE, + ); + const names = await this.bookingsRepository.resolveStaffNames( + rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]), + ); + + const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id)); + const invoices = invoiceIds.length + ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) + : []; + const invoiceById = new Map(invoices.map((i) => [i.id, i])); + + return rows.map((r) => { + const file = filesByCharge.get(r.id)?.[0]; + return { + id: r.id, + bookingId: r.bookingId, + reason: r.reason, + status: r.status, + amount: Number(r.amount), + currency: r.currency, + file: file ? { id: file.id, name: file.name, url: file.url } : null, + invoiceId: r.invoiceId ?? null, + invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null, + paymentReference: r.paymentReference ?? null, + createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null, + createdAt: r.createdAt.toISOString(), + sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null, + sentAt: r.sentAt?.toISOString() ?? null, + paidAt: r.paidAt?.toISOString() ?? null, + cancelledAt: r.cancelledAt?.toISOString() ?? null, + cancelReason: r.cancelReason ?? null, + }; + }); + } +} 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 05d312384..1a9652656 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,8 @@ import type { Response } from "express"; import { BookingClearanceChargeService } from './booking-clearance-charge.service'; import { ClearanceEventService } from './clearance-event.service'; import { BillClearanceChargeDto } from './dto/clearance-charge.dto'; +import { AdditionalChargeService } from './additional-charge.service'; +import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingTransitionService } from './booking-transition.service'; @@ -176,6 +178,7 @@ export class BookingsController { private readonly consolidationApprovalService: ConsolidationApprovalService, private readonly clearanceChargeService: BookingClearanceChargeService, private readonly clearanceEventService: ClearanceEventService, + private readonly additionalChargeService: AdditionalChargeService, ) {} @Post() @@ -1210,6 +1213,61 @@ export class BookingsController { ); } + // ── Additional charges (ad-hoc finance billing) ──────────────────────────── + + @Get(":id/additional-charges") + @MixedAudience(FREIGHT_PERMS.additionalCharges.view) + @ApiOperation({ summary: "Ad-hoc extra charges finance has raised against this booking" }) + async getAdditionalCharges( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + if (!hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view)) { + const booking = await this.bookingsService.findById(id); + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.additionalChargeService.list(id); + } + + @Post(":id/additional-charges") + @BookingStaff(FREIGHT_PERMS.additionalCharges.create) + @UseInterceptors(FileInterceptor("file")) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Finance raises a new additional charge — draft, or send to the customer immediately", + }) + createAdditionalCharge( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Body() dto: CreateAdditionalChargeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.additionalChargeService.create(id, dto, resolveAuthUserId(user), file); + } + + @Post(":id/additional-charges/:chargeId/send") + @BookingStaff(FREIGHT_PERMS.additionalCharges.send) + @ApiOperation({ summary: "Issue the draft charge's payable invoice and notify the customer" }) + sendAdditionalCharge( + @Param("id", ParseUUIDPipe) id: string, + @Param("chargeId", ParseUUIDPipe) chargeId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.additionalChargeService.send(id, chargeId, resolveAuthUserId(user)); + } + + @Post(":id/additional-charges/:chargeId/cancel") + @BookingStaff(FREIGHT_PERMS.additionalCharges.cancel) + @ApiOperation({ summary: "Withdraw a draft or unpaid additional charge" }) + cancelAdditionalCharge( + @Param("id", ParseUUIDPipe) id: string, + @Param("chargeId", ParseUUIDPipe) chargeId: string, + @Body() dto: CancelAdditionalChargeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.additionalChargeService.cancel(id, chargeId, resolveAuthUserId(user), dto.reason); + } + @Post(":id/clearance/output-documents") @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) 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 a522a6225..97128c428 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -38,6 +38,9 @@ import { BookingsService } from './bookings.service'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview } from './entities/booking-document-review.entity'; import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity'; +import { AdditionalCharge } from './entities/additional-charge.entity'; +import { AdditionalChargeRepository } from './additional-charge.repository'; +import { AdditionalChargeService } from './additional-charge.service'; import { BookingClearanceChargeService } from './booking-clearance-charge.service'; import { BookingClearanceEvent } from './entities/booking-clearance-event.entity'; import { ClearanceEventService } from './clearance-event.service'; @@ -82,6 +85,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ConsolidationApproval, BookingClearanceCharge, BookingClearanceEvent, + AdditionalCharge, ]), BillingModule, DocumentsModule, @@ -119,6 +123,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingInvoiceService, BookingClearanceChargeService, ClearanceEventService, + AdditionalChargeRepository, + AdditionalChargeService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts new file mode 100644 index 000000000..eb4d095bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator'; + +export class CreateAdditionalChargeDto { + @ApiProperty({ example: 'Re-weighing fee at Mojo dry port' }) + @IsString() + @Length(1, 2000) + reason!: string; + + @ApiProperty({ example: 4500 }) + @Type(() => Number) + @IsNumber() + @IsPositive() + amount!: number; + + @ApiProperty({ example: 'ETB' }) + @IsString() + @Length(3, 8) + currency!: string; + + /** 'send' issues the invoice + notifies the customer immediately; omit/'draft' just saves it. */ + @ApiPropertyOptional({ enum: ['draft', 'send'], default: 'draft' }) + @IsOptional() + @IsIn(['draft', 'send']) + action?: 'draft' | 'send'; +} + +export class CancelAdditionalChargeDto { + @ApiPropertyOptional({ example: 'Raised in error' }) + @IsOptional() + @IsString() + @Length(1, 2000) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts new file mode 100644 index 000000000..11ea0bdb1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts @@ -0,0 +1,74 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; + +export const ADDITIONAL_CHARGE_STATUSES = [ + 'DRAFT', + 'SENT', + 'PAID', + 'CANCELLED', +] as const; +export type AdditionalChargeStatus = (typeof ADDITIONAL_CHARGE_STATUSES)[number]; + +/** + * An ad-hoc extra charge finance raises against a booking — free-text reason, + * any number per booking (unlike `BookingClearanceCharge`, which caps at one + * per type). DRAFT until finance sends it; sending issues the payable invoice + * and notifies the customer (in-app + SMS + email). PAID via the standard + * `additional_charge.invoice.paid` settlement event. + */ +@Entity({ schema: 'freight', name: 'additional_charge' }) +@Index(['bookingId']) +export class AdditionalCharge extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'reason', type: 'text' }) + reason!: string; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: AdditionalChargeStatus; + + @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 }) + amount!: string; + + @Column({ name: 'currency', type: 'varchar', length: 8 }) + currency!: string; + + /** The supporting attachment (FileRecord), if any. */ + @Column({ name: 'file_record_id', type: 'uuid', nullable: true }) + fileRecordId?: string | null; + + /** The payable invoice issued for this charge (null until SENT). */ + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + /** CBE bill reference / PNR the customer pays against, once issued. */ + @Column({ name: 'payment_reference', type: 'varchar', length: 64, nullable: true }) + paymentReference?: string | null; + + @Column({ name: 'created_by_staff_id', type: 'uuid', nullable: true }) + createdByStaffId?: string | null; + + @Column({ name: 'sent_by_staff_id', type: 'uuid', nullable: true }) + sentByStaffId?: string | null; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt?: Date | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt?: Date | null; + + @Column({ name: 'cancelled_by_staff_id', type: 'uuid', nullable: true }) + cancelledByStaffId?: string | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date | null; + + @Column({ name: 'cancel_reason', type: 'text', nullable: true }) + cancelReason?: string | null; +} 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 6e4a289c5..7e5a2f93a 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1330,6 +1330,30 @@ export const PORT_TERMINAL_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// E. Additional charges — ad-hoc finance charges raised against a booking +export const ADDITIONAL_CHARGE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f2e00001-0001-4000-8000-000000000001", + "edr_freight_app:additional_charges:view", + "View additional charges", + ), + perm( + "f2e00001-0001-4000-8000-000000000002", + "edr_freight_app:additional_charges:create", + "Create additional charge", + ), + perm( + "f2e00001-0001-4000-8000-000000000003", + "edr_freight_app:additional_charges:send", + "Send additional charge to customer", + ), + perm( + "f2e00001-0001-4000-8000-000000000004", + "edr_freight_app:additional_charges:cancel", + "Cancel additional charge", + ), +]; + // E'. Train-scheduling finer actions (augment existing view/manage) export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1713,6 +1737,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:warehouse_fee_invoices:get_notification", "Receive warehouse fee accrual notifications", ), + perm( + "f3a00001-0001-4000-8000-000000000009", + "edr_freight_app:additional_charges:get_notification", + "Receive additional charge notifications", + ), ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ @@ -1726,6 +1755,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...FLEET_ROAD_PERMISSIONS, ...WAREHOUSE_PERMISSIONS, ...PORT_TERMINAL_PERMISSIONS, + ...ADDITIONAL_CHARGE_PERMISSIONS, ...SCHEDULING_EXTRA_PERMISSIONS, ...CONFIG_SETTINGS_PERMISSIONS, ...STAFF_IAM_PERMISSIONS, @@ -2170,6 +2200,14 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:warehouse_fee_invoices:get_notification", }, + additionalCharges: { + view: "edr_freight_app:additional_charges:view", + create: "edr_freight_app:additional_charges:create", + send: "edr_freight_app:additional_charges:send", + cancel: "edr_freight_app:additional_charges:cancel", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:additional_charges:get_notification", + }, settings: { fileUpload: { view: "edr_freight_app:settings:file_upload:view", @@ -2345,6 +2383,9 @@ export const NOTIFICATION_PERMISSION_ANCHORS: Record = { [FREIGHT_PERMS.warehouseFeeInvoices.getNotification]: [ FREIGHT_PERMS.warehouseFeeInvoices.view, ], + [FREIGHT_PERMS.additionalCharges.getNotification]: [ + FREIGHT_PERMS.additionalCharges.view, + ], }; /** Both arms of a freight-type-split permission (for one-of route guards). */ diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 5b7de12d5..31b026c4e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -1,10 +1,11 @@ import { useNavigate } from "react-router-dom"; -import { ExternalLink, MoreHorizontal } from "lucide-react"; +import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react"; import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { useBookingActionDialog } from "./useBookingActionDialog"; import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions"; import { isAllocateAction, isClearanceNavAction, @@ -50,6 +51,14 @@ export function BookingActionsMenu({ const goToClearanceTab = () => navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`); + const goToAdditionalCharges = () => + navigate(`/dashboard/booking-requests/${row.id}?tab=additional-charges`); + + const canSeeAdditionalCharges = hasFreightPermission( + user, + FREIGHT_PERMS.additionalCharges.view, + ); + const handleAction = (action: (typeof actions)[number]) => { onSuppressRowClick?.(); if (isContractNavAction(action.id)) { @@ -144,6 +153,17 @@ export function BookingActionsMenu({ ); })} {actions.length > 0 && } + {canSeeAdditionalCharges && ( + } + onClick={() => { + onSuppressRowClick?.(); + goToAdditionalCharges(); + }} + > + Additional charges + + )} } onClick={() => { diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 279338844..177976b28 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -340,6 +340,12 @@ export const FREIGHT_PERMS = { cancel: "edr_freight_app:warehouse_fee_invoices:cancel", pay: "edr_freight_app:warehouse_fee_invoices:pay", }, + additionalCharges: { + view: "edr_freight_app:additional_charges:view", + create: "edr_freight_app:additional_charges:create", + send: "edr_freight_app:additional_charges:send", + cancel: "edr_freight_app:additional_charges:cancel", + }, /** * Audit trail. View-only — the API exposes no write routes for audit rows, * so there is no manage/delete counterpart to grant. diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index e17a60939..1453d9217 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -200,6 +200,11 @@ export enum InvoiceSource { * per-booking link. */ ShippingLineCredit = "shipping_line_credit", + /** + * Ad-hoc extra charge finance raises against a booking (e.g. a fee not + * covered by an existing fee rule). `sourceId` is the charge id. + */ + AdditionalCharge = "additional_charge", } export enum SchedulingStatus { @@ -865,6 +870,36 @@ export interface ClearanceDocRequest { at: string; } +// ── Additional charges (ad-hoc finance billing) ────────────────────────────── + +/** + * DRAFT: staff is still editing, nothing sent. SENT: invoice issued to the + * customer (in-app + SMS + email). PAID: the invoice settled. CANCELLED: + * withdrawn before payment. + */ +export type AdditionalChargeStatus = "DRAFT" | "SENT" | "PAID" | "CANCELLED"; + +/** One ad-hoc extra charge finance raised against a booking. */ +export interface AdditionalCharge { + id: string; + bookingId: string; + reason: string; + status: AdditionalChargeStatus; + amount: number; + currency: string; + file: { id: string; name: string; url: string } | null; + invoiceId: string | null; + invoiceNumber: string | null; + paymentReference: string | null; + createdByName: string | null; + createdAt: string; + sentByName: string | null; + sentAt: string | null; + paidAt: string | null; + cancelledAt: string | null; + cancelReason: string | null; +} + /** One entry of a clearance document's audit trail, oldest first. */ export interface ClearanceDocumentEvent { type: "UPLOADED" | "RESUBMITTED" | "QUERIED" | "APPROVED"; From 6e6d6329b69ea8bbdb3e01053eca7c8799f72e98 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 20 Aug 2026 12:54:37 +0000 Subject: [PATCH 2/3] feat(bookings): add Additional Payments tab, add-charge modal, portal pay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backoffice: new tab on the booking detail page (?tab=additional-charges, already linked from the row menu) with an Add Charge modal — Save draft or Send to customer. Portal: charges panel on the booking detail page, Pay now per charge via the existing OTP/CBE-bill payment flow. Also fixes: GET /bookings/:id/additional-charges was returning DRAFT charges to the customer (hidden client-side only) — now filtered server-side per caller. --- .../modules/bookings/bookings.controller.ts | 7 +- .../bookings/AdditionalPaymentsTab.tsx | 397 ++++++++++++++++++ .../bookings/BookingRequestDetailPage.tsx | 29 +- .../src/services/bookings.service.ts | 47 +++ .../BookingDetailPage/ReadonlyBookingView.tsx | 2 + .../components/AdditionalChargesPanel.tsx | 129 ++++++ .../portal/src/services/bookings.service.ts | 5 + 7 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx 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 1a9652656..e122a8800 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -1222,11 +1222,14 @@ export class BookingsController { @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { - if (!hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view)) { + const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view); + if (!isStaff) { const booking = await this.bookingsService.findById(id); await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } - return this.additionalChargeService.list(id); + const charges = await this.additionalChargeService.list(id); + // A charge finance hasn't sent yet isn't the customer's to see. + return isStaff ? charges : charges.filter((c) => c.status !== "DRAFT"); } @Post(":id/additional-charges") diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx new file mode 100644 index 000000000..38fda81e1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx @@ -0,0 +1,397 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Badge, + Box, + Button, + FileButton, + Group, + Loader, + Modal, + NumberInput, + Paper, + Select, + Stack, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import { + Ban, + Download, + Eye, + FileText, + Plus, + Receipt, + Send, + Upload, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; + +import { bookingsService } from "@/services/bookings.service"; +import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; +import { formatDateTime } from "@/lib/format"; +import { extractErrorMessage } from "@/utils/errorExtractor"; + +const CURRENCIES = ["ETB", "USD"]; + +const STATUS_META: Record = { + DRAFT: { label: "Draft", color: "gray" }, + SENT: { label: "Sent — unpaid", color: "orange" }, + PAID: { label: "Paid", color: "edr-green" }, + CANCELLED: { label: "Cancelled", color: "red" }, +}; + +export interface AdditionalPaymentsTabProps { + bookingId: string; + onViewFile: (file: { name: string; url: string }) => void; +} + +/** + * Ad-hoc extra charges finance raises against a booking — any number, free-text + * reason. Draft until sent; sending issues the payable invoice and notifies the + * customer (in-app + SMS + email). Settles the same way every invoice does. + */ +export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPaymentsTabProps) { + const qc = useQueryClient(); + const [modalOpen, setModalOpen] = useState(false); + + const { data: charges, isLoading } = useQuery({ + queryKey: ["additional-charges", bookingId], + queryFn: () => bookingsService.getAdditionalCharges(bookingId), + }); + + const refresh = (next: Freight.AdditionalCharge[]) => + qc.setQueryData(["additional-charges", bookingId], next); + const onError = (e: unknown) => + toast.error(extractErrorMessage(e, "Could not update the charge")); + + const create = useMutation({ + mutationFn: (p: { + reason: string; + amount: number; + currency: string; + action: "draft" | "send"; + file?: File | null; + }) => bookingsService.createAdditionalCharge(bookingId, p), + onSuccess: (next, p) => { + toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved"); + refresh(next); + setModalOpen(false); + }, + onError, + }); + const send = useMutation({ + mutationFn: (chargeId: string) => bookingsService.sendAdditionalCharge(bookingId, chargeId), + onSuccess: (next) => { + toast.success("Charge sent to the customer"); + refresh(next); + }, + onError, + }); + const cancel = useMutation({ + mutationFn: (chargeId: string) => bookingsService.cancelAdditionalCharge(bookingId, chargeId), + onSuccess: (next) => { + toast.success("Charge cancelled"); + refresh(next); + }, + onError, + }); + + if (isLoading) { + return ( + + + Loading additional charges… + + ); + } + + const rows = charges ?? []; + const busy = send.isPending || cancel.isPending; + + return ( + + + + Additional charges + + + + + {rows.length === 0 && ( + + No additional charges raised on this booking yet. + + )} + + {rows.map((charge) => ( + send.mutate(charge.id)} + onCancel={() => cancel.mutate(charge.id)} + /> + ))} + + setModalOpen(false)} + busy={create.isPending} + onSubmit={(p) => create.mutate(p)} + /> + + ); +} + +function ChargeCard({ + charge, + busy, + onViewFile, + onSend, + onCancel, +}: { + charge: Freight.AdditionalCharge; + busy: boolean; + onViewFile: (file: { name: string; url: string }) => void; + onSend: () => void; + onCancel: () => void; +}) { + const meta = STATUS_META[charge.status]; + + return ( + + + + + + + {charge.reason} + + + Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "} + {formatDateTime(charge.createdAt)} + + {charge.sentAt && ( + + Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "} + {formatDateTime(charge.sentAt)} + {charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""} + + )} + {charge.paidAt && ( + + Paid · {formatDateTime(charge.paidAt)} + {charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""} + + )} + {charge.cancelledAt && ( + + Cancelled · {formatDateTime(charge.cancelledAt)} + {charge.cancelReason ? ` — ${charge.cancelReason}` : ""} + + )} + + + + + {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.currency} + + + {meta.label} + + + + + {charge.file && ( + + + + {charge.file.name} + + {isViewable({ name: charge.file.name, url: "" }) && ( + + + void fetchViewableFile(charge.file!.id, charge.file!.name).then(onViewFile) + } + c="edr-green" + style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }} + > + + + + )} + + void downloadBookingFile(charge.file!.id, charge.file!.name)} + c="edr-green" + style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }} + > + + + + + )} + + {(charge.status === "DRAFT" || charge.status === "SENT") && ( + + + {charge.status === "DRAFT" && ( + + )} + + )} + + ); +} + +function AddChargeModal({ + opened, + onClose, + busy, + onSubmit, +}: { + opened: boolean; + onClose: () => void; + busy: boolean; + onSubmit: (p: { + reason: string; + amount: number; + currency: string; + action: "draft" | "send"; + file?: File | null; + }) => void; +}) { + const [reason, setReason] = useState(""); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [file, setFile] = useState(null); + + const valid = reason.trim().length > 0 && Number(amount) > 0; + + const reset = () => { + setReason(""); + setAmount(""); + setCurrency("ETB"); + setFile(null); + }; + + const submit = (action: "draft" | "send") => { + if (!valid) return; + onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file }); + }; + + return ( + { + onClose(); + reset(); + }} + title="Add additional charge" + radius="md" + centered + > + +