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

@@ -1,9 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { Readable } from "stream";
import { DataSource } from "typeorm";
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { MinioService } from "../minio/minio.service";
import { StampSettingsRepository } from "./stamp-settings.repository";
import { StampSetting } from "./entities/stamp-setting.entity";
@@ -28,7 +26,6 @@ export class StampSettingsService {
private readonly repository: StampSettingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {}
/** The settings row, created empty on first access. */
@@ -53,14 +50,12 @@ export class StampSettingsService {
* `data:` URL or null. Never throws — document generation must succeed even
* if the stamp lookup fails; callers fall back to their own seal on null.
*
* The data-URL-or-null guarantee is load-bearing, not cosmetic. Callers do
* two things with this value that a bare MinIO URL silently corrupts:
* ContractTransitionService base64-decodes it to snapshot the seal onto a
* signature row (a URL decodes to garbage bytes, not an error, permanently
* sealing an executed contract with a broken image), and the HTML render
* path inlines it into an <img> that headless Chromium cannot fetch. So
* where getView() may hand a raw URL to a browser that can load it, this
* degrades to null and lets the caller draw its text/vector seal instead.
* The data-URL-or-null guarantee is load-bearing, not cosmetic: the HTML
* render paths inline this value into an <img> that headless Chromium
* cannot fetch over the network. So where getView() may hand a raw URL to
* a browser that can load it, this degrades to null and lets the caller
* draw its text/vector seal instead. (Contract signing no longer consumes
* this — staff signatures reference the stampFileId directly.)
*/
async getStampImageUrl(): Promise<string | null> {
try {
@@ -81,13 +76,20 @@ export class StampSettingsService {
}
}
/** Replace the stamp image, storing it in MinIO via FilesService. */
/**
* Replace the stamp image, storing it in MinIO via FilesService.
*
* The replaced file is NEVER deleted: contract signatures reference stamp
* files by id (ContractTransitionService points staff signatures at the
* current stampFileId instead of copying the image), so each retired file
* is the immutable record of which seal executed the contracts signed while
* it was current. Deleting it would strip the seal off those contracts.
*/
async setStamp(
stampImageBase64: string,
updatedById?: string | null,
): Promise<StampSettingView> {
const current = await this.get();
const previousFileId = current.stampFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: current.id,
@@ -102,28 +104,22 @@ export class StampSettingsService {
updatedById: updatedById ?? null,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`);
return this.getView();
}
/** Clear the stamp (invoices fall back to the programmatic seal). */
/**
* Clear the stamp (invoices fall back to the programmatic seal). The file
* is kept for the same reason as in {@link setStamp}.
*/
async clearStamp(updatedById?: string | null): Promise<StampSettingView> {
const current = await this.get();
const previousFileId = current.stampFileId ?? null;
await this.repository.update(current.id, {
stampFileId: null,
updatedById: updatedById ?? null,
});
if (previousFileId) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
return this.getView();
}