mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { logCtx } from "@edr/api-common";
|
|||||||
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
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
|
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||||
// payers straight off the table.
|
// payers straight off the table.
|
||||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||||
@@ -1958,6 +1959,14 @@ export class BillingService {
|
|||||||
.getRepository(Booking)
|
.getRepository(Booking)
|
||||||
.update({ id: invoice.sourceId }, { pnrCode: billReference });
|
.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);
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||||
// billing must not simulate it. Kept for local demos only.
|
// billing must not simulate it. Kept for local demos only.
|
||||||
|
|||||||
@@ -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<AdditionalCharge> {
|
||||||
|
constructor(@InjectRepository(AdditionalCharge) repository: Repository<AdditionalCharge>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AdditionalCharge> {
|
||||||
|
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<Freight.AdditionalCharge[]> {
|
||||||
|
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<Freight.AdditionalCharge[]> {
|
||||||
|
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<Freight.AdditionalCharge[]> {
|
||||||
|
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<AdditionalCharge> {
|
||||||
|
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<void> {
|
||||||
|
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<Freight.AdditionalCharge[]> {
|
||||||
|
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<void> {
|
||||||
|
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<Freight.AdditionalCharge[]> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ import type { Response } from "express";
|
|||||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||||
import { ClearanceEventService } from './clearance-event.service';
|
import { ClearanceEventService } from './clearance-event.service';
|
||||||
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
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 { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingTransitionService } from './booking-transition.service';
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
@@ -176,6 +178,7 @@ export class BookingsController {
|
|||||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||||
private readonly clearanceChargeService: BookingClearanceChargeService,
|
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||||
private readonly clearanceEventService: ClearanceEventService,
|
private readonly clearanceEventService: ClearanceEventService,
|
||||||
|
private readonly additionalChargeService: AdditionalChargeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@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")
|
@Post(":id/clearance/output-documents")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ import { BookingsService } from './bookings.service';
|
|||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||||
import { BookingClearanceCharge } from './entities/booking-clearance-charge.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 { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||||
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
|
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
|
||||||
import { ClearanceEventService } from './clearance-event.service';
|
import { ClearanceEventService } from './clearance-event.service';
|
||||||
@@ -82,6 +85,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
ConsolidationApproval,
|
ConsolidationApproval,
|
||||||
BookingClearanceCharge,
|
BookingClearanceCharge,
|
||||||
BookingClearanceEvent,
|
BookingClearanceEvent,
|
||||||
|
AdditionalCharge,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
@@ -119,6 +123,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
BookingClearanceChargeService,
|
BookingClearanceChargeService,
|
||||||
ClearanceEventService,
|
ClearanceEventService,
|
||||||
|
AdditionalChargeRepository,
|
||||||
|
AdditionalChargeService,
|
||||||
ContractTemplateResolver,
|
ContractTemplateResolver,
|
||||||
ContractViewModelBuilder,
|
ContractViewModelBuilder,
|
||||||
ContractPricingScheduleBuilder,
|
ContractPricingScheduleBuilder,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
// E'. Train-scheduling finer actions (augment existing view/manage)
|
||||||
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
perm(
|
perm(
|
||||||
@@ -1713,6 +1737,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:warehouse_fee_invoices:get_notification",
|
"edr_freight_app:warehouse_fee_invoices:get_notification",
|
||||||
"Receive warehouse fee accrual notifications",
|
"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[] = [
|
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
@@ -1726,6 +1755,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
...FLEET_ROAD_PERMISSIONS,
|
...FLEET_ROAD_PERMISSIONS,
|
||||||
...WAREHOUSE_PERMISSIONS,
|
...WAREHOUSE_PERMISSIONS,
|
||||||
...PORT_TERMINAL_PERMISSIONS,
|
...PORT_TERMINAL_PERMISSIONS,
|
||||||
|
...ADDITIONAL_CHARGE_PERMISSIONS,
|
||||||
...SCHEDULING_EXTRA_PERMISSIONS,
|
...SCHEDULING_EXTRA_PERMISSIONS,
|
||||||
...CONFIG_SETTINGS_PERMISSIONS,
|
...CONFIG_SETTINGS_PERMISSIONS,
|
||||||
...STAFF_IAM_PERMISSIONS,
|
...STAFF_IAM_PERMISSIONS,
|
||||||
@@ -2170,6 +2200,14 @@ export const FREIGHT_PERMS = {
|
|||||||
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
||||||
getNotification: "edr_freight_app:warehouse_fee_invoices:get_notification",
|
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: {
|
settings: {
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
view: "edr_freight_app:settings:file_upload:view",
|
view: "edr_freight_app:settings:file_upload:view",
|
||||||
@@ -2345,6 +2383,9 @@ export const NOTIFICATION_PERMISSION_ANCHORS: Record<string, string[]> = {
|
|||||||
[FREIGHT_PERMS.warehouseFeeInvoices.getNotification]: [
|
[FREIGHT_PERMS.warehouseFeeInvoices.getNotification]: [
|
||||||
FREIGHT_PERMS.warehouseFeeInvoices.view,
|
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). */
|
/** Both arms of a freight-type-split permission (for one-of route guards). */
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
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 { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||||
import {
|
import {
|
||||||
isAllocateAction,
|
isAllocateAction,
|
||||||
isClearanceNavAction,
|
isClearanceNavAction,
|
||||||
@@ -50,6 +51,14 @@ export function BookingActionsMenu({
|
|||||||
const goToClearanceTab = () =>
|
const goToClearanceTab = () =>
|
||||||
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
|
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]) => {
|
const handleAction = (action: (typeof actions)[number]) => {
|
||||||
onSuppressRowClick?.();
|
onSuppressRowClick?.();
|
||||||
if (isContractNavAction(action.id)) {
|
if (isContractNavAction(action.id)) {
|
||||||
@@ -144,6 +153,17 @@ export function BookingActionsMenu({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{actions.length > 0 && <Menu.Divider />}
|
{actions.length > 0 && <Menu.Divider />}
|
||||||
|
{canSeeAdditionalCharges && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Receipt size={15} />}
|
||||||
|
onClick={() => {
|
||||||
|
onSuppressRowClick?.();
|
||||||
|
goToAdditionalCharges();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Additional charges
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<ExternalLink size={15} />}
|
leftSection={<ExternalLink size={15} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -340,6 +340,12 @@ export const FREIGHT_PERMS = {
|
|||||||
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
||||||
pay: "edr_freight_app:warehouse_fee_invoices:pay",
|
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,
|
* Audit trail. View-only — the API exposes no write routes for audit rows,
|
||||||
* so there is no manage/delete counterpart to grant.
|
* so there is no manage/delete counterpart to grant.
|
||||||
|
|||||||
@@ -200,6 +200,11 @@ export enum InvoiceSource {
|
|||||||
* per-booking link.
|
* per-booking link.
|
||||||
*/
|
*/
|
||||||
ShippingLineCredit = "shipping_line_credit",
|
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 {
|
export enum SchedulingStatus {
|
||||||
@@ -865,6 +870,36 @@ export interface ClearanceDocRequest {
|
|||||||
at: string;
|
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. */
|
/** One entry of a clearance document's audit trail, oldest first. */
|
||||||
export interface ClearanceDocumentEvent {
|
export interface ClearanceDocumentEvent {
|
||||||
type: "UPLOADED" | "RESUBMITTED" | "QUERIED" | "APPROVED";
|
type: "UPLOADED" | "RESUBMITTED" | "QUERIED" | "APPROVED";
|
||||||
|
|||||||
Reference in New Issue
Block a user