mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-finalization clearance charges billed to the customer: one PORT_CHARGES
|
||||||
|
* and one MISCELLANEOUS row max per booking, each carrying a document, amount,
|
||||||
|
* currency and its own payable invoice.
|
||||||
|
*/
|
||||||
|
export class BookingClearanceCharge3590000000000 implements MigrationInterface {
|
||||||
|
name = 'BookingClearanceCharge3590000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_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,
|
||||||
|
"type" character varying(20) NOT NULL,
|
||||||
|
"status" character varying(20) NOT NULL DEFAULT 'DOC_UPLOADED',
|
||||||
|
"file_record_id" uuid,
|
||||||
|
"amount" numeric(14,2),
|
||||||
|
"currency" character varying(8),
|
||||||
|
"invoice_id" uuid,
|
||||||
|
"uploaded_by_staff_id" uuid,
|
||||||
|
"uploaded_at" timestamptz,
|
||||||
|
"billed_by_staff_id" uuid,
|
||||||
|
"billed_at" timestamptz,
|
||||||
|
"paid_at" timestamptz,
|
||||||
|
CONSTRAINT "pk_booking_clearance_charge" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "fk_booking_clearance_charge_booking" FOREIGN KEY ("booking_id")
|
||||||
|
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_booking_type"
|
||||||
|
ON "freight"."booking_clearance_charge" ("booking_id", "type")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
|
import { Invoice } from '../billing/entities/invoice.entity';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import {
|
||||||
|
BookingClearanceCharge,
|
||||||
|
ClearanceChargeType,
|
||||||
|
} from './entities/booking-clearance-charge.entity';
|
||||||
|
|
||||||
|
/** File-record codes the charge documents are stored under on the booking. */
|
||||||
|
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
|
||||||
|
PORT_CHARGES: 'clearance_charge_port',
|
||||||
|
MISCELLANEOUS: 'clearance_charge_misc',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
|
||||||
|
PORT_CHARGES: 'Port charges',
|
||||||
|
MISCELLANEOUS: 'Miscellaneous charges',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-finalization clearance charges billed to the customer. Two levels per
|
||||||
|
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
|
||||||
|
* (amount + currency) and sends the invoice; once that invoice is paid GL
|
||||||
|
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
|
||||||
|
* through the portal gateway, other currencies through Finance's manual
|
||||||
|
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingClearanceChargeService {
|
||||||
|
private readonly logger = new Logger(BookingClearanceChargeService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private repo() {
|
||||||
|
return this.dataSource.getRepository(BookingClearanceCharge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charges are a post-finalization step: block while the customer's clearance
|
||||||
|
* documents are still being collected/reviewed.
|
||||||
|
*/
|
||||||
|
private assertClearanceFinalized(booking: Booking): void {
|
||||||
|
const inReview =
|
||||||
|
booking.status === 'AWAITING_DOCUMENTS' ||
|
||||||
|
booking.status === 'DOCUMENTS_UNDER_REVIEW';
|
||||||
|
if (inReview && !booking.preClearanceFinalizedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Clearance charges open after document clearance is finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(bookingId: string): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const charges = await this.repo().find({
|
||||||
|
where: { bookingId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
if (charges.length === 0) return [];
|
||||||
|
|
||||||
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||||
|
const fileById = new Map(files.map((f) => [f.id, f]));
|
||||||
|
const names = await this.bookingsRepository.resolveStaffNames(
|
||||||
|
charges.flatMap((c) => [c.uploadedByStaffId, c.billedByStaffId]),
|
||||||
|
);
|
||||||
|
const invoiceIds = charges
|
||||||
|
.map((c) => c.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 charges.map((c) => {
|
||||||
|
const file = c.fileRecordId ? (fileById.get(c.fileRecordId) ?? null) : null;
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
bookingId: c.bookingId,
|
||||||
|
type: c.type,
|
||||||
|
status: c.status,
|
||||||
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||||
|
amount: c.amount != null ? Number(c.amount) : null,
|
||||||
|
currency: c.currency ?? null,
|
||||||
|
invoiceId: c.invoiceId ?? null,
|
||||||
|
invoiceNumber: c.invoiceId
|
||||||
|
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
|
||||||
|
: null,
|
||||||
|
uploadedByName: c.uploadedByStaffId
|
||||||
|
? (names.get(c.uploadedByStaffId) ?? null)
|
||||||
|
: null,
|
||||||
|
uploadedAt: c.uploadedAt ? c.uploadedAt.toISOString() : null,
|
||||||
|
billedByName: c.billedByStaffId
|
||||||
|
? (names.get(c.billedByStaffId) ?? null)
|
||||||
|
: null,
|
||||||
|
billedAt: c.billedAt ? c.billedAt.toISOString() : null,
|
||||||
|
paidAt: c.paidAt ? c.paidAt.toISOString() : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
|
||||||
|
async uploadPortDocument(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
staffId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
this.assertClearanceFinalized(booking);
|
||||||
|
|
||||||
|
const existing = await this.repo().findOne({
|
||||||
|
where: { bookingId, type: 'PORT_CHARGES' },
|
||||||
|
});
|
||||||
|
if (existing && existing.status !== 'DOC_UPLOADED') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The port charge has already been billed — ask GL Ethiopia to revise it instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.filesService.upsertByCode(
|
||||||
|
{
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: CHARGE_FILE_CODE.PORT_CHARGES,
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
{ userId: staffId },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await this.repo().update(existing.id, {
|
||||||
|
fileRecordId: record.id,
|
||||||
|
uploadedByStaffId: staffId,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.repo().save(
|
||||||
|
this.repo().create({
|
||||||
|
bookingId,
|
||||||
|
type: 'PORT_CHARGES',
|
||||||
|
status: 'DOC_UPLOADED',
|
||||||
|
fileRecordId: record.id,
|
||||||
|
uploadedByStaffId: staffId,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Ethiopia sets (or, on the customer's request, revises) amount +
|
||||||
|
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
|
||||||
|
* is immutable.
|
||||||
|
*/
|
||||||
|
async billCharge(
|
||||||
|
bookingId: string,
|
||||||
|
chargeId: string,
|
||||||
|
input: { amount: number; currency: string },
|
||||||
|
staffId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const charge = await this.repo().findOne({
|
||||||
|
where: { id: chargeId, bookingId },
|
||||||
|
});
|
||||||
|
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||||
|
if (charge.status === 'PAID') {
|
||||||
|
throw new ConflictException('A paid charge can no longer be changed.');
|
||||||
|
}
|
||||||
|
if (!(input.amount > 0)) {
|
||||||
|
throw new BadRequestException('Amount must be greater than zero.');
|
||||||
|
}
|
||||||
|
if (!input.currency?.trim()) {
|
||||||
|
throw new BadRequestException('Currency is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (charge.status === 'SENT' && charge.invoiceId) {
|
||||||
|
await this.billing.cancelInvoice(charge.invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repo().update(charge.id, {
|
||||||
|
amount: input.amount.toFixed(2),
|
||||||
|
currency: input.currency.trim().toUpperCase(),
|
||||||
|
status: 'BILLED',
|
||||||
|
invoiceId: null,
|
||||||
|
billedByStaffId: staffId,
|
||||||
|
billedAt: new Date(),
|
||||||
|
});
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL Ethiopia issues the payable invoice to the customer. */
|
||||||
|
async sendCharge(
|
||||||
|
bookingId: string,
|
||||||
|
chargeId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const charge = await this.repo().findOne({
|
||||||
|
where: { id: chargeId, bookingId },
|
||||||
|
});
|
||||||
|
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||||
|
if (charge.status !== 'BILLED') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Set the amount and currency before sending the charge to the customer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
const invoice = await this.billing.generateInvoice({
|
||||||
|
source: Freight.InvoiceSource.ClearanceCharge,
|
||||||
|
// The charge's own id, NOT the booking id — booking-scoped invoice
|
||||||
|
// lookups (findPayable/expirePayable/CBE billQuery) must never match it.
|
||||||
|
sourceId: charge.id,
|
||||||
|
type: charge.type,
|
||||||
|
companyId: booking.companyId,
|
||||||
|
companyProfileId: booking.companyProfileId,
|
||||||
|
currency: charge.currency ?? 'ETB',
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: charge.type,
|
||||||
|
description: `${CHARGE_LABEL[charge.type]} — ${booking.reference ?? bookingId}`,
|
||||||
|
amount: Number(charge.amount),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.repo().update(charge.id, {
|
||||||
|
status: 'SENT',
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
|
||||||
|
);
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
|
||||||
|
* currency). Second payment level: allowed only once the port charge is paid.
|
||||||
|
*/
|
||||||
|
async createMiscellaneous(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
input: { amount: number; currency: string },
|
||||||
|
staffId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
this.assertClearanceFinalized(booking);
|
||||||
|
|
||||||
|
const port = await this.repo().findOne({
|
||||||
|
where: { bookingId, type: 'PORT_CHARGES' },
|
||||||
|
});
|
||||||
|
if (port?.status !== 'PAID') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Miscellaneous charges open after the port charge is paid.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = await this.repo().findOne({
|
||||||
|
where: { bookingId, type: 'MISCELLANEOUS' },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This booking already has a miscellaneous charge — revise it instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(input.amount > 0)) {
|
||||||
|
throw new BadRequestException('Amount must be greater than zero.');
|
||||||
|
}
|
||||||
|
if (!input.currency?.trim()) {
|
||||||
|
throw new BadRequestException('Currency is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.filesService.upsertByCode(
|
||||||
|
{
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: CHARGE_FILE_CODE.MISCELLANEOUS,
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
{ userId: staffId },
|
||||||
|
);
|
||||||
|
await this.repo().save(
|
||||||
|
this.repo().create({
|
||||||
|
bookingId,
|
||||||
|
type: 'MISCELLANEOUS',
|
||||||
|
status: 'BILLED',
|
||||||
|
fileRecordId: record.id,
|
||||||
|
amount: input.amount.toFixed(2),
|
||||||
|
currency: input.currency.trim().toUpperCase(),
|
||||||
|
uploadedByStaffId: staffId,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
billedByStaffId: staffId,
|
||||||
|
billedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return this.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
|
||||||
|
@OnEvent('clearance_charge.invoice.paid')
|
||||||
|
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
|
const charge = await this.repo().findOne({
|
||||||
|
where: { id: payload.sourceId },
|
||||||
|
});
|
||||||
|
if (!charge || charge.status === 'PAID') return;
|
||||||
|
await this.repo().update(charge.id, {
|
||||||
|
status: 'PAID',
|
||||||
|
paidAt: new Date(),
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -38,6 +39,8 @@ import {
|
|||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
|
||||||
|
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||||
|
import { BillClearanceChargeDto } from './dto/clearance-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';
|
||||||
@@ -170,6 +173,7 @@ export class BookingsController {
|
|||||||
private readonly userTradeAccessService: UserTradeAccessService,
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||||
|
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -1073,6 +1077,96 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Clearance charges (post-finalization customer billing) ────────────────
|
||||||
|
|
||||||
|
@Get(":id/clearance/charges")
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
|
])
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Clearance charges billed to the customer (port + miscellaneous)",
|
||||||
|
})
|
||||||
|
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.clearanceChargeService.list(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/charges/port-document")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document",
|
||||||
|
})
|
||||||
|
uploadPortChargeDocument(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
if (!file) throw new BadRequestException("A document file is required");
|
||||||
|
return this.clearanceChargeService.uploadPortDocument(
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id/clearance/charges/:chargeId/bill")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
|
||||||
|
})
|
||||||
|
billClearanceCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||||
|
@Body() dto: BillClearanceChargeDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceChargeService.billCharge(
|
||||||
|
id,
|
||||||
|
chargeId,
|
||||||
|
dto,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/charges/:chargeId/send")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
|
||||||
|
})
|
||||||
|
sendClearanceCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||||
|
) {
|
||||||
|
return this.clearanceChargeService.sendCharge(id, chargeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/clearance/charges/miscellaneous")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
|
||||||
|
})
|
||||||
|
createMiscellaneousCharge(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body() dto: BillClearanceChargeDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
if (!file) throw new BadRequestException("A document file is required");
|
||||||
|
return this.clearanceChargeService.createMiscellaneous(
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
dto,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/clearance/output-documents")
|
@Post(":id/clearance/output-documents")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import { ContainerValidationService } from './container-validation.service';
|
|||||||
import { BookingsService } from './bookings.service';
|
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 { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
@@ -76,6 +78,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
CustomerTruckAssignment,
|
CustomerTruckAssignment,
|
||||||
CustomerTruckContainer,
|
CustomerTruckContainer,
|
||||||
ConsolidationApproval,
|
ConsolidationApproval,
|
||||||
|
BookingClearanceCharge,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
@@ -111,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
BookingContractService,
|
BookingContractService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
|
BookingClearanceChargeService,
|
||||||
ContractTemplateResolver,
|
ContractTemplateResolver,
|
||||||
ContractViewModelBuilder,
|
ContractViewModelBuilder,
|
||||||
ContractPricingScheduleBuilder,
|
ContractPricingScheduleBuilder,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class BillClearanceChargeDto {
|
||||||
|
@ApiProperty({ example: 12500.5 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'ETB' })
|
||||||
|
@IsString()
|
||||||
|
@Length(3, 8)
|
||||||
|
currency!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
|
export const CLEARANCE_CHARGE_TYPES = ['PORT_CHARGES', 'MISCELLANEOUS'] as const;
|
||||||
|
export type ClearanceChargeType = (typeof CLEARANCE_CHARGE_TYPES)[number];
|
||||||
|
|
||||||
|
export const CLEARANCE_CHARGE_STATUSES = [
|
||||||
|
'DOC_UPLOADED',
|
||||||
|
'BILLED',
|
||||||
|
'SENT',
|
||||||
|
'PAID',
|
||||||
|
] as const;
|
||||||
|
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-finalization clearance charge billed to the customer — at most one
|
||||||
|
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the
|
||||||
|
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency
|
||||||
|
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid`
|
||||||
|
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only
|
||||||
|
* after the port charge is paid.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
|
||||||
|
@Index(['bookingId', 'type'], { unique: true })
|
||||||
|
export class BookingClearanceCharge extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'type', type: 'varchar', length: 20 })
|
||||||
|
type!: ClearanceChargeType;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DOC_UPLOADED' })
|
||||||
|
status!: ClearanceChargeStatus;
|
||||||
|
|
||||||
|
/** The supporting charge document (FileRecord). */
|
||||||
|
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||||
|
fileRecordId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||||
|
amount?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
|
||||||
|
currency?: string | null;
|
||||||
|
|
||||||
|
/** The payable invoice issued for this charge (null until SENT). */
|
||||||
|
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||||
|
invoiceId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'uploaded_by_staff_id', type: 'uuid', nullable: true })
|
||||||
|
uploadedByStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'uploaded_at', type: 'timestamptz', nullable: true })
|
||||||
|
uploadedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'billed_by_staff_id', type: 'uuid', nullable: true })
|
||||||
|
billedByStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'billed_at', type: 'timestamptz', nullable: true })
|
||||||
|
billedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||||
|
paidAt?: Date | null;
|
||||||
|
}
|
||||||
@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
|
|||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the booking-contract view reads the global stamp live, the contracts
|
* The staff signature seals with the ONE global stamp by REFERENCE: the
|
||||||
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
|
* signature row stores the current global stampFileId instead of re-uploading
|
||||||
* company stamp can never restamp an already-executed contract. These specs
|
* a copy per contract. That id stays valid after the stamp is replaced
|
||||||
* pin the sourcing split: EDR always seals with the global stamp and staff
|
* (StampSettingsService never deletes retired stamp files), so each contract
|
||||||
* never supply one, while the customer must upload their own.
|
* keeps the exact seal it was signed with. These specs pin the sourcing
|
||||||
|
* split: EDR always seals with the global stamp and staff never supply one,
|
||||||
|
* while the customer must upload their own.
|
||||||
*/
|
*/
|
||||||
describe('applySignature stamp sourcing', () => {
|
describe('applySignature stamp sourcing', () => {
|
||||||
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
|
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
|
||||||
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
|
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
|
||||||
|
const GLOBAL_STAMP_FILE_ID = 'file-global-stamp';
|
||||||
|
|
||||||
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
|
const build = (globalStampFileId: string | null = GLOBAL_STAMP_FILE_ID) => {
|
||||||
const uploads: Array<{ code: string; image: string }> = [];
|
const uploads: Array<{ code: string; image: string }> = [];
|
||||||
const saved: unknown[] = [];
|
const saved: unknown[] = [];
|
||||||
const service = Object.create(
|
const service = Object.create(
|
||||||
@@ -22,7 +25,10 @@ describe('applySignature stamp sourcing', () => {
|
|||||||
Object.assign(service, {
|
Object.assign(service, {
|
||||||
logger: { warn: jest.fn(), log: jest.fn() },
|
logger: { warn: jest.fn(), log: jest.fn() },
|
||||||
stampSettings: {
|
stampSettings: {
|
||||||
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
|
get: jest.fn().mockResolvedValue({
|
||||||
|
id: 's-1',
|
||||||
|
stampFileId: globalStampFileId,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
contractsRepository: {
|
contractsRepository: {
|
||||||
saveSignature: jest.fn((row: unknown) => {
|
saveSignature: jest.fn((row: unknown) => {
|
||||||
@@ -62,35 +68,36 @@ describe('applySignature stamp sourcing', () => {
|
|||||||
signatureImageBase64: 'data:image/png;base64,U0lH',
|
signatureImageBase64: 'data:image/png;base64,U0lH',
|
||||||
};
|
};
|
||||||
|
|
||||||
it('seals the EDR side with the global stamp', async () => {
|
it('seals the EDR side by referencing the global stamp file, without re-uploading it', async () => {
|
||||||
const { service, uploads, saved } = build();
|
const { service, uploads, saved } = build();
|
||||||
|
|
||||||
await apply(service, staffDto);
|
await apply(service, staffDto);
|
||||||
|
|
||||||
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
|
expect(uploads.map((u) => u.code)).toEqual(['signature_staff']);
|
||||||
expect(saved[0]).toEqual(
|
expect(saved[0]).toEqual(
|
||||||
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
|
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores a stamp a staff client tries to supply', async () => {
|
it('ignores a stamp a staff client tries to supply', async () => {
|
||||||
const { service, uploads } = build();
|
const { service, uploads, saved } = build();
|
||||||
|
|
||||||
await apply(service, {
|
await apply(service, {
|
||||||
...staffDto,
|
...staffDto,
|
||||||
stampImageBase64: 'data:image/png;base64,SEFDSw==',
|
stampImageBase64: 'data:image/png;base64,SEFDSw==',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
|
|
||||||
expect(uploads.map((u) => u.image)).not.toContain(
|
expect(uploads.map((u) => u.image)).not.toContain(
|
||||||
'data:image/png;base64,SEFDSw==',
|
'data:image/png;base64,SEFDSw==',
|
||||||
);
|
);
|
||||||
|
expect(saved[0]).toEqual(
|
||||||
|
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Failing loudly matters here: getStampImageUrl degrades to null when the
|
* Failing loudly matters here: silently executing an unsealed contract
|
||||||
* stamp cannot be inlined, and silently executing an unsealed contract would
|
* would be worse than refusing to counter-sign.
|
||||||
* be worse than refusing to counter-sign.
|
|
||||||
*/
|
*/
|
||||||
it('refuses to counter-sign when no global stamp is configured', async () => {
|
it('refuses to counter-sign when no global stamp is configured', async () => {
|
||||||
const { service, saved } = build(null);
|
const { service, saved } = build(null);
|
||||||
|
|||||||
@@ -1128,17 +1128,27 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot whichever stamp applies onto the signature row rather than
|
// STAFF seals by REFERENCE to the one global stamp file — no per-contract
|
||||||
// referencing the global one, so replacing the company stamp later can
|
// copy of the image. Safe because StampSettingsService.setStamp/clearStamp
|
||||||
// never restamp an already-executed contract.
|
// never delete a replaced stamp file: the referenced id keeps rendering
|
||||||
let stampImageBase64 = dto.stampImageBase64 ?? null;
|
// the exact seal that was current at signing, even after the global stamp
|
||||||
|
// is later replaced. The customer's stamp is their own upload and is still
|
||||||
|
// stored per contract.
|
||||||
|
let stampFileId: string | null = null;
|
||||||
if (role === 'STAFF') {
|
if (role === 'STAFF') {
|
||||||
stampImageBase64 = await this.stampSettings.getStampImageUrl();
|
stampFileId = (await this.stampSettings.get()).stampFileId ?? null;
|
||||||
if (!stampImageBase64) {
|
if (!stampFileId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
|
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (dto.stampImageBase64) {
|
||||||
|
const stampRecord = await this.uploadSignatureAsset(
|
||||||
|
contract,
|
||||||
|
`stamp_${role.toLowerCase()}`,
|
||||||
|
dto.stampImageBase64,
|
||||||
|
);
|
||||||
|
stampFileId = stampRecord.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileRecord = await this.uploadSignatureAsset(
|
const fileRecord = await this.uploadSignatureAsset(
|
||||||
@@ -1146,13 +1156,6 @@ export class ContractTransitionService {
|
|||||||
`signature_${role.toLowerCase()}`,
|
`signature_${role.toLowerCase()}`,
|
||||||
imageBase64,
|
imageBase64,
|
||||||
);
|
);
|
||||||
const stampRecord = stampImageBase64
|
|
||||||
? await this.uploadSignatureAsset(
|
|
||||||
contract,
|
|
||||||
`stamp_${role.toLowerCase()}`,
|
|
||||||
stampImageBase64,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await this.contractsRepository.saveSignature({
|
await this.contractsRepository.saveSignature({
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
@@ -1160,7 +1163,7 @@ export class ContractTransitionService {
|
|||||||
signerDisplayName,
|
signerDisplayName,
|
||||||
signedAt: new Date(),
|
signedAt: new Date(),
|
||||||
signatureFileId: fileRecord.id,
|
signatureFileId: fileRecord.id,
|
||||||
stampFileId: stampRecord?.id ?? null,
|
stampFileId,
|
||||||
consentText: dto.consentText ?? null,
|
consentText: dto.consentText ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import { DataSource } from "typeorm";
|
|
||||||
|
|
||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
import { FileRecord } from "../files/entities/file.entity";
|
|
||||||
import { MinioService } from "../minio/minio.service";
|
import { MinioService } from "../minio/minio.service";
|
||||||
import { StampSettingsRepository } from "./stamp-settings.repository";
|
import { StampSettingsRepository } from "./stamp-settings.repository";
|
||||||
import { StampSetting } from "./entities/stamp-setting.entity";
|
import { StampSetting } from "./entities/stamp-setting.entity";
|
||||||
@@ -28,7 +26,6 @@ export class StampSettingsService {
|
|||||||
private readonly repository: StampSettingsRepository,
|
private readonly repository: StampSettingsRepository,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** The settings row, created empty on first access. */
|
/** The settings row, created empty on first access. */
|
||||||
@@ -53,14 +50,12 @@ export class StampSettingsService {
|
|||||||
* `data:` URL or null. Never throws — document generation must succeed even
|
* `data:` URL or null. Never throws — document generation must succeed even
|
||||||
* if the stamp lookup fails; callers fall back to their own seal on null.
|
* if the stamp lookup fails; callers fall back to their own seal on null.
|
||||||
*
|
*
|
||||||
* The data-URL-or-null guarantee is load-bearing, not cosmetic. Callers do
|
* The data-URL-or-null guarantee is load-bearing, not cosmetic: the HTML
|
||||||
* two things with this value that a bare MinIO URL silently corrupts:
|
* render paths inline this value into an <img> that headless Chromium
|
||||||
* ContractTransitionService base64-decodes it to snapshot the seal onto a
|
* cannot fetch over the network. So where getView() may hand a raw URL to
|
||||||
* signature row (a URL decodes to garbage bytes, not an error, permanently
|
* a browser that can load it, this degrades to null and lets the caller
|
||||||
* sealing an executed contract with a broken image), and the HTML render
|
* draw its text/vector seal instead. (Contract signing no longer consumes
|
||||||
* path inlines it into an <img> that headless Chromium cannot fetch. So
|
* this — staff signatures reference the stampFileId directly.)
|
||||||
* where getView() may hand a raw URL to a browser that can load it, this
|
|
||||||
* degrades to null and lets the caller draw its text/vector seal instead.
|
|
||||||
*/
|
*/
|
||||||
async getStampImageUrl(): Promise<string | null> {
|
async getStampImageUrl(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@@ -81,13 +76,20 @@ export class StampSettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the stamp image, storing it in MinIO via FilesService. */
|
/**
|
||||||
|
* Replace the stamp image, storing it in MinIO via FilesService.
|
||||||
|
*
|
||||||
|
* The replaced file is NEVER deleted: contract signatures reference stamp
|
||||||
|
* files by id (ContractTransitionService points staff signatures at the
|
||||||
|
* current stampFileId instead of copying the image), so each retired file
|
||||||
|
* is the immutable record of which seal executed the contracts signed while
|
||||||
|
* it was current. Deleting it would strip the seal off those contracts.
|
||||||
|
*/
|
||||||
async setStamp(
|
async setStamp(
|
||||||
stampImageBase64: string,
|
stampImageBase64: string,
|
||||||
updatedById?: string | null,
|
updatedById?: string | null,
|
||||||
): Promise<StampSettingView> {
|
): Promise<StampSettingView> {
|
||||||
const current = await this.get();
|
const current = await this.get();
|
||||||
const previousFileId = current.stampFileId ?? null;
|
|
||||||
|
|
||||||
const fileRecord = await this.filesService.upload({
|
const fileRecord = await this.filesService.upload({
|
||||||
resourceId: current.id,
|
resourceId: current.id,
|
||||||
@@ -102,28 +104,22 @@ export class StampSettingsService {
|
|||||||
updatedById: updatedById ?? null,
|
updatedById: updatedById ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (previousFileId && previousFileId !== fileRecord.id) {
|
|
||||||
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`);
|
this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`);
|
||||||
return this.getView();
|
return this.getView();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the stamp (invoices fall back to the programmatic seal). */
|
/**
|
||||||
|
* Clear the stamp (invoices fall back to the programmatic seal). The file
|
||||||
|
* is kept for the same reason as in {@link setStamp}.
|
||||||
|
*/
|
||||||
async clearStamp(updatedById?: string | null): Promise<StampSettingView> {
|
async clearStamp(updatedById?: string | null): Promise<StampSettingView> {
|
||||||
const current = await this.get();
|
const current = await this.get();
|
||||||
const previousFileId = current.stampFileId ?? null;
|
|
||||||
|
|
||||||
await this.repository.update(current.id, {
|
await this.repository.update(current.id, {
|
||||||
stampFileId: null,
|
stampFileId: null,
|
||||||
updatedById: updatedById ?? null,
|
updatedById: updatedById ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (previousFileId) {
|
|
||||||
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getView();
|
return this.getView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
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<
|
||||||
|
Freight.ClearanceChargeStatus,
|
||||||
|
{ label: string; color: string }
|
||||||
|
> = {
|
||||||
|
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
|
||||||
|
BILLED: { label: "Ready to send", color: "blue" },
|
||||||
|
SENT: { label: "Sent — unpaid", color: "orange" },
|
||||||
|
PAID: { label: "Paid", color: "edr-green" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ClearanceChargesTabProps {
|
||||||
|
bookingId: string;
|
||||||
|
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
|
||||||
|
roleMode: "ET" | "DJ";
|
||||||
|
onViewFile: (file: { name: string; url: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-finalization charges billed to the customer, two levels: port charges
|
||||||
|
* (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous
|
||||||
|
* (created whole by GL Ethiopia once the port charge is paid). Each level
|
||||||
|
* issues its own payable invoice — ETB settles through the portal gateway
|
||||||
|
* (CBE), other currencies through Finance's manual settlement.
|
||||||
|
*/
|
||||||
|
export function ClearanceChargesTab({
|
||||||
|
bookingId,
|
||||||
|
roleMode,
|
||||||
|
onViewFile,
|
||||||
|
}: ClearanceChargesTabProps) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data: charges, isLoading } = useQuery({
|
||||||
|
queryKey: ["clearance-charges", bookingId],
|
||||||
|
queryFn: () => bookingsService.getClearanceCharges(bookingId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = (next: Freight.ClearanceCharge[]) =>
|
||||||
|
qc.setQueryData(["clearance-charges", bookingId], next);
|
||||||
|
const onError = (e: unknown) =>
|
||||||
|
toast.error(extractErrorMessage(e, "Could not update the charge"));
|
||||||
|
|
||||||
|
const uploadPort = useMutation({
|
||||||
|
mutationFn: (file: File) =>
|
||||||
|
bookingsService.uploadPortChargeDocument(bookingId, file),
|
||||||
|
onSuccess: (next) => {
|
||||||
|
toast.success("Port-charges document uploaded");
|
||||||
|
refresh(next);
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
const bill = useMutation({
|
||||||
|
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
|
||||||
|
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
|
||||||
|
onSuccess: (next) => {
|
||||||
|
toast.success("Charge amount saved");
|
||||||
|
refresh(next);
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
const send = useMutation({
|
||||||
|
mutationFn: (chargeId: string) =>
|
||||||
|
bookingsService.sendClearanceCharge(bookingId, chargeId),
|
||||||
|
onSuccess: (next) => {
|
||||||
|
toast.success("Invoice sent to the customer");
|
||||||
|
refresh(next);
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
const createMisc = useMutation({
|
||||||
|
mutationFn: (p: { file: File; amount: number; currency: string }) =>
|
||||||
|
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
|
||||||
|
onSuccess: (next) => {
|
||||||
|
toast.success("Miscellaneous charge created");
|
||||||
|
refresh(next);
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Group justify="center" py="xl" gap={10}>
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
<Text c="dimmed">Loading charges…</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null;
|
||||||
|
const misc = (charges ?? []).find((c) => c.type === "MISCELLANEOUS") ?? null;
|
||||||
|
const busy =
|
||||||
|
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
|
||||||
|
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const c of charges ?? []) {
|
||||||
|
if (c.amount != null && c.currency)
|
||||||
|
totals.set(c.currency, (totals.get(c.currency) ?? 0) + c.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md" maw={860}>
|
||||||
|
<ChargeCard
|
||||||
|
title="1 · Port charges"
|
||||||
|
charge={port}
|
||||||
|
roleMode={roleMode}
|
||||||
|
busy={busy}
|
||||||
|
emptyHint={
|
||||||
|
roleMode === "DJ"
|
||||||
|
? "Upload the port-charges document to start this charge."
|
||||||
|
: "Waiting for GL Djibouti to upload the port-charges document."
|
||||||
|
}
|
||||||
|
onViewFile={onViewFile}
|
||||||
|
onBill={(amount, currency) =>
|
||||||
|
port && bill.mutate({ chargeId: port.id, amount, currency })
|
||||||
|
}
|
||||||
|
onSend={() => port && send.mutate(port.id)}
|
||||||
|
djUpload={
|
||||||
|
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
|
||||||
|
<FileButton
|
||||||
|
onChange={(f) => f && uploadPort.mutate(f)}
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={14} />}
|
||||||
|
loading={uploadPort.isPending}
|
||||||
|
>
|
||||||
|
{port ? "Replace document" : "Upload document"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChargeCard
|
||||||
|
title="2 · Miscellaneous charges"
|
||||||
|
charge={misc}
|
||||||
|
roleMode={roleMode}
|
||||||
|
busy={busy}
|
||||||
|
emptyHint={
|
||||||
|
port?.status !== "PAID"
|
||||||
|
? "Unlocks once the port charge is paid."
|
||||||
|
: roleMode === "ET"
|
||||||
|
? "Create the miscellaneous charge with its document, amount and currency."
|
||||||
|
: "GL Ethiopia creates this charge once the port charge is paid."
|
||||||
|
}
|
||||||
|
onViewFile={onViewFile}
|
||||||
|
onBill={(amount, currency) =>
|
||||||
|
misc && bill.mutate({ chargeId: misc.id, amount, currency })
|
||||||
|
}
|
||||||
|
onSend={() => misc && send.mutate(misc.id)}
|
||||||
|
etCreate={
|
||||||
|
roleMode === "ET" && !misc && port?.status === "PAID" ? (
|
||||||
|
<MiscCreateForm
|
||||||
|
busy={createMisc.isPending}
|
||||||
|
onCreate={(file, amount, currency) =>
|
||||||
|
createMisc.mutate({ file, amount, currency })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{totals.size > 0 && (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fz="13px" fw={700} c="edr-text">
|
||||||
|
Total billed
|
||||||
|
</Text>
|
||||||
|
<Group gap="md">
|
||||||
|
{[...totals.entries()].map(([currency, amount]) => (
|
||||||
|
<Text key={currency} fz="14px" fw={800} c="edr-text">
|
||||||
|
{amount.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}{" "}
|
||||||
|
{currency}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChargeCard({
|
||||||
|
title,
|
||||||
|
charge,
|
||||||
|
roleMode,
|
||||||
|
busy,
|
||||||
|
emptyHint,
|
||||||
|
onViewFile,
|
||||||
|
onBill,
|
||||||
|
onSend,
|
||||||
|
djUpload,
|
||||||
|
etCreate,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
charge: Freight.ClearanceCharge | null;
|
||||||
|
roleMode: "ET" | "DJ";
|
||||||
|
busy: boolean;
|
||||||
|
emptyHint: string;
|
||||||
|
onViewFile: (file: { name: string; url: string }) => void;
|
||||||
|
onBill: (amount: number, currency: string) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
djUpload?: React.ReactNode;
|
||||||
|
etCreate?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
|
||||||
|
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
|
||||||
|
|
||||||
|
const status = charge?.status ?? null;
|
||||||
|
const meta = status ? STATUS_META[status] : null;
|
||||||
|
// ET enters/revises the amount while the charge is unpaid.
|
||||||
|
const showBillForm =
|
||||||
|
roleMode === "ET" &&
|
||||||
|
charge != null &&
|
||||||
|
(charge.status === "DOC_UPLOADED" || editing);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
|
||||||
|
<Box>
|
||||||
|
<Text fz="14px" fw={700} c="edr-text">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{charge?.uploadedAt && (
|
||||||
|
<Text fz="11.5px" c="dimmed">
|
||||||
|
Document uploaded
|
||||||
|
{charge.uploadedByName ? ` by ${charge.uploadedByName}` : ""} ·{" "}
|
||||||
|
{formatDateTime(charge.uploadedAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{charge?.billedAt && (
|
||||||
|
<Text fz="11.5px" c="dimmed">
|
||||||
|
Billed{charge.billedByName ? ` by ${charge.billedByName}` : ""} ·{" "}
|
||||||
|
{formatDateTime(charge.billedAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{charge?.paidAt && (
|
||||||
|
<Text fz="11.5px" c="edr-green.8" fw={600}>
|
||||||
|
Paid · {formatDateTime(charge.paidAt)}
|
||||||
|
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
{charge?.amount != null && charge.currency && (
|
||||||
|
<Text fz="14px" fw={800} c="edr-text">
|
||||||
|
{charge.amount.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}{" "}
|
||||||
|
{charge.currency}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{meta && (
|
||||||
|
<Badge variant="light" color={meta.color} radius="sm">
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{charge?.file && (
|
||||||
|
<Group gap={8} mt="sm" wrap="nowrap">
|
||||||
|
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
|
||||||
|
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0 }}>
|
||||||
|
{charge.file.name}
|
||||||
|
</Text>
|
||||||
|
{isViewable({ name: charge.file.name, url: "" }) && (
|
||||||
|
<Tooltip label="View">
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(
|
||||||
|
charge.file!.id,
|
||||||
|
charge.file!.name,
|
||||||
|
).then(onViewFile)
|
||||||
|
}
|
||||||
|
c="edr-green"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Download">
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(charge.file!.id, charge.file!.name)
|
||||||
|
}
|
||||||
|
c="edr-green"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!charge && (
|
||||||
|
<Text fz="12.5px" c="dimmed" mt="xs">
|
||||||
|
{emptyHint}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{djUpload && <Box mt="sm">{djUpload}</Box>}
|
||||||
|
{etCreate && <Box mt="sm">{etCreate}</Box>}
|
||||||
|
|
||||||
|
{showBillForm && (
|
||||||
|
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
|
||||||
|
<NumberInput
|
||||||
|
label="Amount"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
min={0.01}
|
||||||
|
decimalScale={2}
|
||||||
|
value={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
w={160}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Currency"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
data={CURRENCIES}
|
||||||
|
value={currency}
|
||||||
|
onChange={(v) => v && setCurrency(v)}
|
||||||
|
w={100}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
disabled={busy || !(Number(amount) > 0)}
|
||||||
|
onClick={() => {
|
||||||
|
onBill(Number(amount), currency);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save amount
|
||||||
|
</Button>
|
||||||
|
{editing && (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
|
||||||
|
<Group mt="sm" gap={8} justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setAmount(charge.amount ?? "");
|
||||||
|
setCurrency(charge.currency ?? "ETB");
|
||||||
|
setEditing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
|
||||||
|
</Button>
|
||||||
|
{charge.status === "BILLED" && (
|
||||||
|
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Send size={14} />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onSend}
|
||||||
|
>
|
||||||
|
Send invoice to customer
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{charge.status === "SENT" && charge.invoiceNumber && (
|
||||||
|
<Badge variant="light" color="orange" radius="sm">
|
||||||
|
Invoice {charge.invoiceNumber}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{charge?.status === "PAID" && (
|
||||||
|
<Group mt="sm" gap={6} justify="flex-end">
|
||||||
|
<CheckCircle2 size={14} color="var(--mantine-color-edr-green-6)" />
|
||||||
|
<Text fz="12px" c="edr-green.8" fw={600}>
|
||||||
|
Settled
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiscCreateForm({
|
||||||
|
busy,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
onCreate: (file: File, amount: number, currency: string) => void;
|
||||||
|
}) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [amount, setAmount] = useState<number | string>("");
|
||||||
|
const [currency, setCurrency] = useState("ETB");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={8} align="flex-end" wrap="wrap">
|
||||||
|
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={14} />}
|
||||||
|
>
|
||||||
|
{file ? file.name : "Choose document"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
<NumberInput
|
||||||
|
label="Amount"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
min={0.01}
|
||||||
|
decimalScale={2}
|
||||||
|
value={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
w={160}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Currency"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
data={CURRENCIES}
|
||||||
|
value={currency}
|
||||||
|
onChange={(v) => v && setCurrency(v)}
|
||||||
|
w={100}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
disabled={busy || !file || !(Number(amount) > 0)}
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => file && onCreate(file, Number(amount), currency)}
|
||||||
|
>
|
||||||
|
Create charge
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Badge, Stack, Tabs, Text } from "@mantine/core";
|
import { Badge, Stack, Tabs, Text } from "@mantine/core";
|
||||||
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
|
import { AlertTriangle, FileText, Receipt, Share2, ShieldAlert } from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
@@ -9,6 +9,7 @@ import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
|||||||
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
|
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
|
||||||
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
|
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
|
||||||
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
||||||
|
|
||||||
export interface ClearanceOpsTabsProps {
|
export interface ClearanceOpsTabsProps {
|
||||||
@@ -68,6 +69,12 @@ export function ClearanceOpsTabs({
|
|||||||
Boolean(exchangeEntityId) &&
|
Boolean(exchangeEntityId) &&
|
||||||
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
|
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
|
||||||
|
// Post-finalization customer billing. This layout is only rendered on the ET
|
||||||
|
// clearance pages — the DJ page (GlClearanceDetailPage) mounts its own tab.
|
||||||
|
const showCharges =
|
||||||
|
Boolean(bookingId) &&
|
||||||
|
Boolean(onViewFile) &&
|
||||||
|
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||||
// Risk assignment + incident reporting hit bookings:operations endpoints.
|
// Risk assignment + incident reporting hit bookings:operations endpoints.
|
||||||
const canOps = hasPermission(user, FREIGHT_PERMS.bookings.operations);
|
const canOps = hasPermission(user, FREIGHT_PERMS.bookings.operations);
|
||||||
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
|
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
|
||||||
@@ -100,6 +107,11 @@ export function ClearanceOpsTabs({
|
|||||||
Document exchange
|
Document exchange
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showCharges ? (
|
||||||
|
<Tabs.Tab value="charges" leftSection={<Receipt size={14} />}>
|
||||||
|
Customer charges
|
||||||
|
</Tabs.Tab>
|
||||||
|
) : null}
|
||||||
{showOpsTabs && canOps && riskMs ? (
|
{showOpsTabs && canOps && riskMs ? (
|
||||||
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
|
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
|
||||||
Risk assignment
|
Risk assignment
|
||||||
@@ -131,6 +143,16 @@ export function ClearanceOpsTabs({
|
|||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showCharges ? (
|
||||||
|
<Tabs.Panel value="charges">
|
||||||
|
<ClearanceChargesTab
|
||||||
|
bookingId={bookingId!}
|
||||||
|
roleMode="ET"
|
||||||
|
onViewFile={onViewFile!}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showOpsTabs && canOps && riskMs && bookingId ? (
|
{showOpsTabs && canOps && riskMs && bookingId ? (
|
||||||
<Tabs.Panel value="risk">
|
<Tabs.Panel value="risk">
|
||||||
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
|
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
|
Receipt,
|
||||||
Share2,
|
Share2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -36,6 +37,7 @@ import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyC
|
|||||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||||
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
||||||
|
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
|
||||||
import {
|
import {
|
||||||
GlClearanceUploadModal,
|
GlClearanceUploadModal,
|
||||||
type GlClearanceUploadKind,
|
type GlClearanceUploadKind,
|
||||||
@@ -247,6 +249,11 @@ export default function GlClearanceDetailPage() {
|
|||||||
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
|
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
|
||||||
Document exchange
|
Document exchange
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
{data.kind === "booking" ? (
|
||||||
|
<Tabs.Tab value="charges" leftSection={<Receipt size={14} />}>
|
||||||
|
Customer charges
|
||||||
|
</Tabs.Tab>
|
||||||
|
) : null}
|
||||||
{incidentBookingId ? (
|
{incidentBookingId ? (
|
||||||
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
||||||
Incidents
|
Incidents
|
||||||
@@ -369,6 +376,12 @@ export default function GlClearanceDetailPage() {
|
|||||||
<GlExchangePanel entityId={id!} />
|
<GlExchangePanel entityId={id!} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{data.kind === "booking" ? (
|
||||||
|
<Tabs.Panel value="charges">
|
||||||
|
<ClearanceChargesTab bookingId={id!} roleMode="DJ" onViewFile={view} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{incidentBookingId ? (
|
{incidentBookingId ? (
|
||||||
<Tabs.Panel value="incidents">
|
<Tabs.Panel value="incidents">
|
||||||
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
||||||
|
|||||||
@@ -432,6 +432,69 @@ export const bookingsService = {
|
|||||||
return unwrap(response.data) as Freight.ClearanceView;
|
return unwrap(response.data) as Freight.ClearanceView;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Clearance charges (post-finalization customer billing) ──
|
||||||
|
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
|
||||||
|
const response = await client.get(`/bookings/${id}/clearance/charges`);
|
||||||
|
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
|
||||||
|
uploadPortChargeDocument: async (
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const response = await client.post(
|
||||||
|
`/bookings/${id}/clearance/charges/port-document`,
|
||||||
|
form,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GL Ethiopia sets or revises a charge's amount + currency. */
|
||||||
|
billClearanceCharge: async (
|
||||||
|
id: string,
|
||||||
|
chargeId: string,
|
||||||
|
payload: { amount: number; currency: string },
|
||||||
|
): Promise<Freight.ClearanceCharge[]> => {
|
||||||
|
const response = await client.patch(
|
||||||
|
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GL Ethiopia issues the charge's payable invoice to the customer. */
|
||||||
|
sendClearanceCharge: async (
|
||||||
|
id: string,
|
||||||
|
chargeId: string,
|
||||||
|
): Promise<Freight.ClearanceCharge[]> => {
|
||||||
|
const response = await client.post(
|
||||||
|
`/bookings/${id}/clearance/charges/${chargeId}/send`,
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
|
||||||
|
createMiscellaneousCharge: async (
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
payload: { amount: number; currency: string },
|
||||||
|
): Promise<Freight.ClearanceCharge[]> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("amount", String(payload.amount));
|
||||||
|
form.append("currency", payload.currency);
|
||||||
|
const response = await client.post(
|
||||||
|
`/bookings/${id}/clearance/charges/miscellaneous`,
|
||||||
|
form,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||||
|
},
|
||||||
|
|
||||||
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
||||||
requestTransitAssignee: (id: string, note?: string) =>
|
requestTransitAssignee: (id: string, note?: string) =>
|
||||||
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
|
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
|
||||||
|
|||||||
@@ -188,6 +188,11 @@ export enum InvoiceSource {
|
|||||||
LastMile = "lastmile",
|
LastMile = "lastmile",
|
||||||
/** Customs clearance service fee — billed on the booking invoice with the freight. */
|
/** Customs clearance service fee — billed on the booking invoice with the freight. */
|
||||||
Clearance = "clearance",
|
Clearance = "clearance",
|
||||||
|
/**
|
||||||
|
* Post-finalization clearance charge (port charges / miscellaneous) billed
|
||||||
|
* to the customer as its own payable invoice. `sourceId` is the charge id.
|
||||||
|
*/
|
||||||
|
ClearanceCharge = "clearance_charge",
|
||||||
/**
|
/**
|
||||||
* A batch of shipping-line credits billed together. Unlike every other
|
* A batch of shipping-line credits billed together. Unlike every other
|
||||||
* source, `sourceId` is the shipping line's id rather than a single record's:
|
* source, `sourceId` is the shipping line's id rather than a single record's:
|
||||||
@@ -802,6 +807,40 @@ export interface PricingBreakdown {
|
|||||||
|
|
||||||
export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED";
|
export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED";
|
||||||
|
|
||||||
|
// ── Clearance charges (post-finalization customer billing) ──────────────────
|
||||||
|
|
||||||
|
export type ClearanceChargeType = "PORT_CHARGES" | "MISCELLANEOUS";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOC_UPLOADED: GL Djibouti uploaded the supporting document (port charges).
|
||||||
|
* BILLED: GL Ethiopia set amount + currency. SENT: invoice issued to the
|
||||||
|
* customer (ETB pays via gateway, other currencies via Finance's manual
|
||||||
|
* settlement). PAID: the invoice settled.
|
||||||
|
*/
|
||||||
|
export type ClearanceChargeStatus =
|
||||||
|
| "DOC_UPLOADED"
|
||||||
|
| "BILLED"
|
||||||
|
| "SENT"
|
||||||
|
| "PAID";
|
||||||
|
|
||||||
|
/** One clearance charge level on a booking — at most one per type. */
|
||||||
|
export interface ClearanceCharge {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
type: ClearanceChargeType;
|
||||||
|
status: ClearanceChargeStatus;
|
||||||
|
file: { id: string; name: string; url: string } | null;
|
||||||
|
amount: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
invoiceId: string | null;
|
||||||
|
invoiceNumber: string | null;
|
||||||
|
uploadedByName: string | null;
|
||||||
|
uploadedAt: string | null;
|
||||||
|
billedByName: string | null;
|
||||||
|
billedAt: string | null;
|
||||||
|
paidAt: 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