mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 21:23:51 +00:00
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.
315 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|