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

This commit is contained in:
Marshal
2026-08-20 05:48:16 +00:00
parent c138da6137
commit c38fcff00d
19 changed files with 663 additions and 16 deletions

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { Freight } from '@edr/types';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import {
BookingClearanceEvent,
ClearanceEventActorType,
} from './entities/booking-clearance-event.entity';
export interface RecordClearanceEventInput {
bookingId: string;
action: string;
/** Human sentence for the History tab, frozen at write time. */
label: string;
actorType?: ClearanceEventActorType;
/** IAM user id (staff or portal customer); name is resolved here. */
actorId?: string | null;
metadata?: Record<string, unknown> | null;
/** Join the caller's transaction so the event commits (or rolls back) with the action. */
manager?: EntityManager;
}
/**
* The clearance History tab's write/read path. Every clearance mutation calls
* {@link record} — document reviews, phased workflow steps, customer charges.
* Recording is deliberately NOT fire-and-forget: the insert shares the caller's
* transaction when a manager is passed, and otherwise a failed insert fails the
* action, because a silent gap in an audit trail is worse than a retry.
*/
@Injectable()
export class ClearanceEventService {
private readonly logger = new Logger(ClearanceEventService.name);
constructor(private readonly dataSource: DataSource) {}
async record(input: RecordClearanceEventInput): Promise<void> {
const mg = input.manager ?? this.dataSource.manager;
const actorName = input.actorId
? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get(
input.actorId,
) ?? null)
: null;
await mg.save(
mg.create(BookingClearanceEvent, {
bookingId: input.bookingId,
action: input.action,
label: input.label,
actorType: input.actorType ?? 'STAFF',
actorId: input.actorId ?? null,
actorName,
metadata: input.metadata ?? null,
}),
);
this.logger.log(
`clearance-history ${input.action} on booking ${input.bookingId}${
actorName ? ` by ${actorName}` : ''
}`,
);
}
/** History for one booking, newest first. */
async list(bookingId: string): Promise<Freight.ClearanceHistoryEvent[]> {
const rows = await this.dataSource
.getRepository(BookingClearanceEvent)
.find({ where: { bookingId }, order: { createdAt: 'DESC' } });
// Rows whose actor name failed to resolve at write time get one more try.
const missing = rows
.filter((r) => !r.actorName && r.actorId)
.map((r) => r.actorId as string);
const names = missing.length
? await resolveIamUserNames(this.dataSource, missing).catch(
() => new Map<string, string>(),
)
: new Map<string, string>();
return rows.map((r) => ({
id: r.id,
action: r.action,
label: r.label,
actorType: r.actorType,
actorName:
r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null),
metadata: r.metadata ?? null,
at: r.createdAt.toISOString(),
}));
}
}