feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices

This commit is contained in:
Marshal
2026-08-20 05:24:57 +00:00
parent 4adb53b486
commit c138da6137
14 changed files with 1281 additions and 53 deletions

View File

@@ -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})`,
);
}
}

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -38,6 +39,8 @@ import {
} from "@nestjs/swagger";
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 { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
@@ -170,6 +173,7 @@ export class BookingsController {
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService,
) {}
@Post()
@@ -1073,6 +1077,96 @@ export class BookingsController {
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")
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -37,6 +37,8 @@ import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
@@ -76,6 +78,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckAssignment,
CustomerTruckContainer,
ConsolidationApproval,
BookingClearanceCharge,
]),
BillingModule,
DocumentsModule,
@@ -111,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,

View File

@@ -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;
}

View File

@@ -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;
}