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

@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Where the booking-contract view reads the global stamp live, the contracts
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
* company stamp can never restamp an already-executed contract. These specs
* pin the sourcing split: EDR always seals with the global stamp and staff
* never supply one, while the customer must upload their own.
* The staff signature seals with the ONE global stamp by REFERENCE: the
* signature row stores the current global stampFileId instead of re-uploading
* a copy per contract. That id stays valid after the stamp is replaced
* (StampSettingsService never deletes retired stamp files), so each contract
* keeps the exact seal it was signed with. These specs pin the sourcing
* split: EDR always seals with the global stamp and staff never supply one,
* while the customer must upload their own.
*/
describe('applySignature stamp sourcing', () => {
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
const GLOBAL_STAMP_FILE_ID = 'file-global-stamp';
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
const build = (globalStampFileId: string | null = GLOBAL_STAMP_FILE_ID) => {
const uploads: Array<{ code: string; image: string }> = [];
const saved: unknown[] = [];
const service = Object.create(
@@ -22,7 +25,10 @@ describe('applySignature stamp sourcing', () => {
Object.assign(service, {
logger: { warn: jest.fn(), log: jest.fn() },
stampSettings: {
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
get: jest.fn().mockResolvedValue({
id: 's-1',
stampFileId: globalStampFileId,
}),
},
contractsRepository: {
saveSignature: jest.fn((row: unknown) => {
@@ -62,35 +68,36 @@ describe('applySignature stamp sourcing', () => {
signatureImageBase64: 'data:image/png;base64,U0lH',
};
it('seals the EDR side with the global stamp', async () => {
it('seals the EDR side by referencing the global stamp file, without re-uploading it', async () => {
const { service, uploads, saved } = build();
await apply(service, staffDto);
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.code)).toEqual(['signature_staff']);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
it('ignores a stamp a staff client tries to supply', async () => {
const { service, uploads } = build();
const { service, uploads, saved } = build();
await apply(service, {
...staffDto,
stampImageBase64: 'data:image/png;base64,SEFDSw==',
});
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.image)).not.toContain(
'data:image/png;base64,SEFDSw==',
);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
/**
* Failing loudly matters here: getStampImageUrl degrades to null when the
* stamp cannot be inlined, and silently executing an unsealed contract would
* be worse than refusing to counter-sign.
* Failing loudly matters here: silently executing an unsealed contract
* would be worse than refusing to counter-sign.
*/
it('refuses to counter-sign when no global stamp is configured', async () => {
const { service, saved } = build(null);

View File

@@ -1128,17 +1128,27 @@ export class ContractTransitionService {
);
}
// Snapshot whichever stamp applies onto the signature row rather than
// referencing the global one, so replacing the company stamp later can
// never restamp an already-executed contract.
let stampImageBase64 = dto.stampImageBase64 ?? null;
// STAFF seals by REFERENCE to the one global stamp file — no per-contract
// copy of the image. Safe because StampSettingsService.setStamp/clearStamp
// never delete a replaced stamp file: the referenced id keeps rendering
// the exact seal that was current at signing, even after the global stamp
// is later replaced. The customer's stamp is their own upload and is still
// stored per contract.
let stampFileId: string | null = null;
if (role === 'STAFF') {
stampImageBase64 = await this.stampSettings.getStampImageUrl();
if (!stampImageBase64) {
stampFileId = (await this.stampSettings.get()).stampFileId ?? null;
if (!stampFileId) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
} else if (dto.stampImageBase64) {
const stampRecord = await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
);
stampFileId = stampRecord.id;
}
const fileRecord = await this.uploadSignatureAsset(
@@ -1146,13 +1156,6 @@ export class ContractTransitionService {
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
stampImageBase64,
)
: null;
await this.contractsRepository.saveSignature({
contractId: contract.id,
@@ -1160,7 +1163,7 @@ export class ContractTransitionService {
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
stampFileId: stampRecord?.id ?? null,
stampFileId,
consentText: dto.consentText ?? null,
});