Files
edr-platform/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts
Hagernesh 8181834037 Ticket
Empty container returns now generate a downloadable Equipment Interchange Receipt: container number & size, exact return timestamp, depot/location, condition, and the carrier/booking reference. Customers can download it from their booking's Documents tab (own-booking returns only); staff can pull any via the same endpoint.
2026-08-21 08:59:06 +00:00

315 lines
12 KiB
TypeScript

import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource, EntityManager } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FilesService } from '../files/files.service';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsService } from './bookings.service';
import { BookingsRepository } from './bookings.repository';
import { AdditionalChargeRepository } from './additional-charge.repository';
import { AdditionalCharge } from './entities/additional-charge.entity';
import { CreateAdditionalChargeDto } from './dto/additional-charge.dto';
const FILE_RESOURCE = 'additional_charges';
/**
* Ad-hoc extra charges finance raises against a booking, independent of
* `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS
* row per booking). Any number per booking, free-text reason. DRAFT until
* sent; sending issues the payable invoice and notifies the customer
* (in-app + SMS + email). Settles via `additional_charge.invoice.paid`,
* same event-driven pattern as every other invoice source.
*/
@Injectable()
export class AdditionalChargeService {
private readonly logger = new Logger(AdditionalChargeService.name);
constructor(
private readonly dataSource: DataSource,
private readonly repository: AdditionalChargeRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly exchangeService: ExchangeService,
private readonly billing: BillingService,
private readonly bookingsService: BookingsService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private async findOwned(bookingId: string, chargeId: string): Promise<AdditionalCharge> {
const charge = await this.repository.findById(chargeId);
if (!charge || charge.bookingId !== bookingId) {
throw new NotFoundException('Additional charge not found');
}
return charge;
}
async list(bookingId: string): Promise<Freight.AdditionalCharge[]> {
const rows = await this.repository.findAll({
where: { bookingId },
order: { createdAt: 'DESC' },
});
return this.toDtoList(rows);
}
async create(
bookingId: string,
dto: CreateAdditionalChargeDto,
staffId: string,
file?: Express.Multer.File,
): Promise<Freight.AdditionalCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
const shouldSend = dto.action === 'send';
const chargeId = await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(AdditionalCharge);
let saved = await repo.save(
repo.create({
bookingId,
reason: dto.reason.trim(),
amount: dto.amount.toFixed(2),
currency: dto.currency.trim().toUpperCase(),
dueAt: dto.dueDate ? new Date(dto.dueDate) : null,
status: 'DRAFT',
createdByStaffId: staffId,
}),
);
if (file) {
const record = await this.filesService.upload({
resourceId: saved.id,
resource: FILE_RESOURCE,
code: FILE_RESOURCE,
file,
uploadedByUserId: staffId,
});
await repo.update(saved.id, { fileRecordId: record.id });
}
if (shouldSend) {
saved = await this.issueInvoice(manager, saved.id, booking, staffId);
}
return saved.id;
});
if (shouldSend) await this.notifyCustomerSent(chargeId);
return this.list(bookingId);
}
async send(bookingId: string, chargeId: string, staffId: string): Promise<Freight.AdditionalCharge[]> {
const charge = await this.findOwned(bookingId, chargeId);
if (charge.status !== 'DRAFT') {
throw new ConflictException('Only a draft charge can be sent.');
}
const booking = await this.bookingsService.findById(bookingId);
await this.dataSource.transaction((manager) =>
this.issueInvoice(manager, charge.id, booking, staffId),
);
await this.notifyCustomerSent(charge.id);
return this.list(bookingId);
}
/** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */
private async issueInvoice(
manager: EntityManager,
chargeId: string,
booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null },
staffId: string,
): Promise<AdditionalCharge> {
const repo = manager.getRepository(AdditionalCharge);
const charge = await repo.findOneByOrFail({ id: chargeId });
const invoice = await this.billing.generateInvoice(
{
source: Freight.InvoiceSource.AdditionalCharge,
sourceId: charge.id,
type: 'ADDITIONAL_CHARGE',
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: charge.currency,
// Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14).
dueAt: charge.dueAt ?? undefined,
lines: [
{
chargeType: 'ADDITIONAL_CHARGE',
description: `${charge.reason}${booking.reference ?? booking.id}`,
amount: Number(charge.amount),
},
],
},
manager,
);
await repo.update(charge.id, {
status: 'SENT',
invoiceId: invoice.id,
sentByStaffId: staffId,
sentAt: new Date(),
});
this.logger.log(
`Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`,
);
return repo.findOneByOrFail({ id: charge.id });
}
private async notifyCustomerSent(chargeId: string): Promise<void> {
try {
const charge = await this.repository.findById(chargeId);
if (!charge) return;
const booking = await this.bookingsService.findById(charge.bookingId);
if (!booking.companyId) return;
const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.INVOICE_ISSUED,
title: 'New charge on your booking',
body,
link: `/bookings/${charge.bookingId}`,
data: {
bookingId: charge.bookingId,
chargeId: charge.id,
amount: Number(charge.amount),
currency: charge.currency,
},
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`);
}
}
async cancel(
bookingId: string,
chargeId: string,
staffId: string,
reason?: string,
): Promise<Freight.AdditionalCharge[]> {
const charge = await this.findOwned(bookingId, chargeId);
if (charge.status !== 'DRAFT' && charge.status !== 'SENT') {
throw new ConflictException('Only a draft or unpaid charge can be cancelled.');
}
if (charge.status === 'SENT' && charge.invoiceId) {
await this.billing.cancelInvoice(charge.invoiceId);
}
await this.repository.update(charge.id, {
status: 'CANCELLED',
cancelledByStaffId: staffId,
cancelledAt: new Date(),
cancelReason: reason ?? null,
});
return this.list(bookingId);
}
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
@OnEvent('additional_charge.invoice.paid')
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
const charge = await this.repository.findById(payload.sourceId);
if (!charge || charge.status === 'PAID') return;
await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() });
try {
const booking = await this.bookingsService.findById(charge.bookingId);
if (!booking.companyId) return;
const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.PAYMENT_RECEIVED,
title: 'Charge payment received',
body,
link: `/bookings/${charge.bookingId}`,
data: { bookingId: charge.bookingId, chargeId: charge.id },
});
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.PAYMENT_RECEIVED,
title: 'Additional charge paid',
body,
link: `/bookings/${charge.bookingId}`,
data: { bookingId: charge.bookingId, chargeId: charge.id },
});
} catch (err) {
this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`);
}
}
private async toDtoList(rows: AdditionalCharge[]): Promise<Freight.AdditionalCharge[]> {
if (!rows.length) return [];
const filesByCharge = await this.filesService.findByResourceIdsGrouped(
rows.map((r) => r.id),
FILE_RESOURCE,
);
const names = await this.bookingsRepository.resolveStaffNames(
rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]),
);
const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id));
const invoices = invoiceIds.length
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
: [];
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
const converted = await Promise.all(rows.map((r) => this.convertAmount(r)));
const convertedById = new Map(rows.map((r, i) => [r.id, converted[i]]));
return rows.map((r) => {
const file = filesByCharge.get(r.id)?.[0];
const fx = convertedById.get(r.id) ?? null;
return {
id: r.id,
bookingId: r.bookingId,
reason: r.reason,
status: r.status,
amount: Number(r.amount),
currency: r.currency,
convertedAmount: fx?.amount ?? null,
convertedCurrency: fx?.currency ?? null,
dueAt: r.dueAt?.toISOString() ?? null,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
invoiceId: r.invoiceId ?? null,
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null,
paymentReference: r.paymentReference ?? null,
createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null,
createdAt: r.createdAt.toISOString(),
sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null,
sentAt: r.sentAt?.toISOString() ?? null,
paidAt: r.paidAt?.toISOString() ?? null,
cancelledAt: r.cancelledAt?.toISOString() ?? null,
cancelReason: r.cancelReason ?? null,
};
});
}
/**
* Amount converted to the other of ETB/USD, via the existing shared
* `ExchangeService` (CBE rate, falls back to the stored `exchange_settings`
* rate) — same mechanism `booking-wagon-cancellation.service.ts` and
* warehouse fee pricing already use. Null on anything but ETB/USD, or if
* the rate feed is down — this is a display convenience, not the payable
* amount, so a failure here must never break the charge list.
*/
private async convertAmount(
charge: AdditionalCharge,
): Promise<{ amount: number; currency: string } | null> {
if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null;
const target = charge.currency === 'ETB' ? 'USD' : 'ETB';
try {
const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target);
return { amount: Math.round(amount * 100) / 100, currency: target };
} catch (err) {
this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`);
return null;
}
}
}