mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
541 lines
19 KiB
TypeScript
541 lines
19 KiB
TypeScript
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 { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
|
import { Booking } from './entities/booking.entity';
|
|
import {
|
|
BookingClearanceCharge,
|
|
ClearanceChargeStatus,
|
|
ClearanceChargeType,
|
|
} from './entities/booking-clearance-charge.entity';
|
|
import { ClearanceEventService } from './clearance-event.service';
|
|
|
|
/** 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',
|
|
};
|
|
|
|
/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */
|
|
export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet<ClearanceChargeStatus> =
|
|
new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
|
|
|
|
/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */
|
|
export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean =>
|
|
status !== 'ACCEPTED' && status !== 'PAID';
|
|
|
|
/**
|
|
* Post-finalization clearance charges billed to the customer: one port charge
|
|
* (document from GL Djibouti, priced by GL Ethiopia) and any number of
|
|
* miscellaneous charges. GL prices + describes a charge and SENDs it; the
|
|
* customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues
|
|
* the payable invoice and locks the 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 readonly clearanceEvents: ClearanceEventService,
|
|
private readonly notifier: BookingLifecycleNotifierService,
|
|
) {}
|
|
|
|
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,
|
|
description: c.description ?? null,
|
|
customerNote: c.customerNote ?? null,
|
|
customerDecidedAt: c.customerDecidedAt
|
|
? c.customerDecidedAt.toISOString()
|
|
: 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,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** The customer's view: only charges GL has sent them. */
|
|
async listForCustomer(bookingId: string): Promise<Freight.ClearanceCharge[]> {
|
|
return (await this.list(bookingId)).filter((c) =>
|
|
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status),
|
|
);
|
|
}
|
|
|
|
private async findCharge(
|
|
bookingId: string,
|
|
chargeId: string,
|
|
): Promise<BookingClearanceCharge> {
|
|
const charge = await this.repo().findOne({
|
|
where: { id: chargeId, bookingId },
|
|
});
|
|
if (!charge) throw new NotFoundException('Clearance charge not found');
|
|
return charge;
|
|
}
|
|
|
|
/** 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(),
|
|
}),
|
|
);
|
|
}
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'CHARGE_PORT_DOC_UPLOADED',
|
|
label: existing
|
|
? 'Replaced the port-charges document'
|
|
: 'Uploaded the port-charges document',
|
|
actorId: staffId,
|
|
metadata: { fileName: file.originalname },
|
|
});
|
|
return this.list(bookingId);
|
|
}
|
|
|
|
/**
|
|
* GL Ethiopia sets (or, after a customer rejection, revises) amount +
|
|
* currency + description. Allowed until the customer accepts: an ACCEPTED
|
|
* charge already carries an invoice and a PAID one is settled.
|
|
*/
|
|
async billCharge(
|
|
bookingId: string,
|
|
chargeId: string,
|
|
input: { amount: number; currency: string; description?: string },
|
|
staffId: string,
|
|
): Promise<Freight.ClearanceCharge[]> {
|
|
const charge = await this.findCharge(bookingId, chargeId);
|
|
if (!canStaffEditCharge(charge.status)) {
|
|
throw new ConflictException(
|
|
'The customer has accepted this charge — it 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.');
|
|
}
|
|
const description = (input.description ?? charge.description ?? '').trim();
|
|
if (charge.type === 'MISCELLANEOUS' && !description) {
|
|
throw new BadRequestException('Describe what this charge is for.');
|
|
}
|
|
const currency = input.currency.trim().toUpperCase();
|
|
const revised = charge.status === 'SENT' || charge.status === 'REJECTED';
|
|
|
|
// Back to draft: the customer's previous decision no longer applies.
|
|
await this.repo().update(charge.id, {
|
|
amount: input.amount.toFixed(2),
|
|
currency,
|
|
description: description || null,
|
|
status: 'BILLED',
|
|
customerNote: null,
|
|
customerDecidedAt: null,
|
|
customerDecidedBy: null,
|
|
billedByStaffId: staffId,
|
|
billedAt: new Date(),
|
|
});
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'CHARGE_BILLED',
|
|
label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
|
|
charge.type
|
|
].toLowerCase()}: ${input.amount} ${currency}${
|
|
description ? ` — ${description}` : ''
|
|
}`,
|
|
actorId: staffId,
|
|
metadata: {
|
|
chargeType: charge.type,
|
|
amount: input.amount,
|
|
currency,
|
|
description: description || null,
|
|
revised,
|
|
},
|
|
});
|
|
return this.list(bookingId);
|
|
}
|
|
|
|
/**
|
|
* GL Ethiopia proposes the priced charge to the customer. No invoice yet —
|
|
* that is issued when the customer accepts. Re-sending after a rejection
|
|
* goes through here too.
|
|
*/
|
|
async sendCharge(
|
|
bookingId: string,
|
|
chargeId: string,
|
|
staffId: string,
|
|
): Promise<Freight.ClearanceCharge[]> {
|
|
const charge = await this.findCharge(bookingId, chargeId);
|
|
if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
|
|
throw new ConflictException(
|
|
charge.status === 'DOC_UPLOADED'
|
|
? 'Set the amount and currency before sending the charge to the customer.'
|
|
: 'This charge has already been sent to the customer.',
|
|
);
|
|
}
|
|
const revised = charge.status === 'REJECTED';
|
|
const amount = Number(charge.amount);
|
|
const currency = charge.currency ?? 'ETB';
|
|
|
|
await this.repo().update(charge.id, {
|
|
status: 'SENT',
|
|
customerNote: null,
|
|
customerDecidedAt: null,
|
|
customerDecidedBy: null,
|
|
});
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'CHARGE_SENT',
|
|
label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[
|
|
charge.type
|
|
].toLowerCase()} to the customer for approval: ${amount} ${currency}`,
|
|
actorId: staffId ?? null,
|
|
metadata: {
|
|
chargeType: charge.type,
|
|
amount,
|
|
currency,
|
|
description: charge.description ?? null,
|
|
revised,
|
|
},
|
|
});
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
this.notifier.clearanceChargeProposed(booking, {
|
|
label: CHARGE_LABEL[charge.type],
|
|
amount,
|
|
currency,
|
|
description: charge.description ?? null,
|
|
revised,
|
|
});
|
|
return this.list(bookingId);
|
|
}
|
|
|
|
/** Customer agrees to the price: the payable invoice is issued and the charge locks. */
|
|
async customerAccept(
|
|
bookingId: string,
|
|
chargeId: string,
|
|
userId: string,
|
|
): Promise<Freight.ClearanceCharge[]> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
|
const charge = await this.findCharge(bookingId, chargeId);
|
|
if (charge.status !== 'SENT' && charge.status !== 'REJECTED') {
|
|
throw new ConflictException(
|
|
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
|
? 'This charge has already been accepted.'
|
|
: 'This charge is not awaiting your decision.',
|
|
);
|
|
}
|
|
const amount = Number(charge.amount);
|
|
const currency = charge.currency ?? 'ETB';
|
|
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,
|
|
lines: [
|
|
{
|
|
chargeType: charge.type,
|
|
description: `${CHARGE_LABEL[charge.type]} — ${
|
|
booking.reference ?? bookingId
|
|
}${charge.description ? `: ${charge.description}` : ''}`,
|
|
amount,
|
|
},
|
|
],
|
|
});
|
|
|
|
await this.repo().update(charge.id, {
|
|
status: 'ACCEPTED',
|
|
invoiceId: invoice.id,
|
|
customerNote: null,
|
|
customerDecidedAt: new Date(),
|
|
customerDecidedBy: userId,
|
|
});
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'CHARGE_ACCEPTED',
|
|
label: `Customer accepted ${CHARGE_LABEL[
|
|
charge.type
|
|
].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`,
|
|
actorType: 'CUSTOMER',
|
|
actorId: userId,
|
|
metadata: {
|
|
chargeType: charge.type,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
amount,
|
|
currency,
|
|
},
|
|
});
|
|
this.notifier.clearanceChargeInvoiceIssued(booking, {
|
|
label: CHARGE_LABEL[charge.type],
|
|
amount,
|
|
currency,
|
|
invoiceNumber: invoice.invoiceNumber,
|
|
});
|
|
this.logger.log(
|
|
`Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`,
|
|
);
|
|
return this.listForCustomer(bookingId);
|
|
}
|
|
|
|
/** Customer declines the price with a reason; GL revises and re-sends. */
|
|
async customerReject(
|
|
bookingId: string,
|
|
chargeId: string,
|
|
note: string,
|
|
userId: string,
|
|
): Promise<Freight.ClearanceCharge[]> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
|
const charge = await this.findCharge(bookingId, chargeId);
|
|
if (charge.status !== 'SENT') {
|
|
throw new ConflictException(
|
|
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
|
? 'This charge has already been accepted.'
|
|
: 'This charge is not awaiting your decision.',
|
|
);
|
|
}
|
|
if (!note?.trim()) {
|
|
throw new BadRequestException('Say why you are rejecting this charge.');
|
|
}
|
|
await this.repo().update(charge.id, {
|
|
status: 'REJECTED',
|
|
customerNote: note.trim(),
|
|
customerDecidedAt: new Date(),
|
|
customerDecidedBy: userId,
|
|
});
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'CHARGE_REJECTED',
|
|
label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`,
|
|
actorType: 'CUSTOMER',
|
|
actorId: userId,
|
|
metadata: { chargeType: charge.type, note: note.trim() },
|
|
});
|
|
this.notifier.clearanceChargeRejectedToStaff(booking, {
|
|
label: CHARGE_LABEL[charge.type],
|
|
note: note.trim(),
|
|
});
|
|
return this.listForCustomer(bookingId);
|
|
}
|
|
|
|
/**
|
|
* GL Ethiopia creates a miscellaneous charge whole (document + amount +
|
|
* currency + what it is for). Lands as a BILLED draft; GL sends it next.
|
|
*/
|
|
async createMiscellaneous(
|
|
bookingId: string,
|
|
file: Express.Multer.File,
|
|
input: { amount: number; currency: string; description?: string },
|
|
staffId: string,
|
|
): Promise<Freight.ClearanceCharge[]> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
this.assertClearanceFinalized(booking);
|
|
|
|
// No ordering and no cap: a miscellaneous charge may be raised before,
|
|
// after or alongside the port charge, and a booking may carry several.
|
|
if (!(input.amount > 0)) {
|
|
throw new BadRequestException('Amount must be greater than zero.');
|
|
}
|
|
if (!input.currency?.trim()) {
|
|
throw new BadRequestException('Currency is required.');
|
|
}
|
|
const description = input.description?.trim() ?? '';
|
|
if (!description) {
|
|
throw new BadRequestException('Describe what this charge is for.');
|
|
}
|
|
|
|
// Save the row first so its id can key the document. A booking may carry
|
|
// several miscellaneous charges, and `upsertByCode` retires whatever sits
|
|
// under the same code — a shared code would silently delete the previous
|
|
// charge's document.
|
|
const charge = await this.repo().save(
|
|
this.repo().create({
|
|
bookingId,
|
|
type: 'MISCELLANEOUS',
|
|
status: 'BILLED',
|
|
amount: input.amount.toFixed(2),
|
|
currency: input.currency.trim().toUpperCase(),
|
|
description,
|
|
uploadedByStaffId: staffId,
|
|
uploadedAt: new Date(),
|
|
billedByStaffId: staffId,
|
|
billedAt: new Date(),
|
|
}),
|
|
);
|
|
const record = await this.filesService.upsertByCode(
|
|
{
|
|
resourceId: bookingId,
|
|
resource: 'bookings',
|
|
code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`,
|
|
file,
|
|
},
|
|
{ userId: staffId },
|
|
);
|
|
await this.repo().update(charge.id, { fileRecordId: record.id });
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'CHARGE_MISC_CREATED',
|
|
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()} — ${description}`,
|
|
actorId: staffId,
|
|
metadata: {
|
|
amount: input.amount,
|
|
currency: input.currency.trim().toUpperCase(),
|
|
description,
|
|
fileName: file.originalname,
|
|
},
|
|
});
|
|
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(),
|
|
});
|
|
await this.clearanceEvents.record({
|
|
bookingId: charge.bookingId,
|
|
action: 'CHARGE_PAID',
|
|
label: `${CHARGE_LABEL[charge.type]} paid (invoice ${payload.invoiceNumber})`,
|
|
actorType: 'SYSTEM',
|
|
metadata: {
|
|
chargeType: charge.type,
|
|
invoiceNumber: payload.invoiceNumber,
|
|
},
|
|
});
|
|
this.logger.log(
|
|
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
|
|
);
|
|
}
|
|
}
|