Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-12 06:44:30 +00:00
94 changed files with 5399 additions and 3583 deletions

View File

@@ -0,0 +1,152 @@
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.
*/
describe('applySignature stamp sourcing', () => {
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
const uploads: Array<{ code: string; image: string }> = [];
const saved: unknown[] = [];
const service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
logger: { warn: jest.fn(), log: jest.fn() },
stampSettings: {
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
},
contractsRepository: {
saveSignature: jest.fn((row: unknown) => {
saved.push(row);
return Promise.resolve(undefined);
}),
},
signaturesService: {
getForUser: jest.fn().mockResolvedValue(null),
upsertForUser: jest.fn().mockResolvedValue(undefined),
},
uploadSignatureAsset: jest.fn((_c: unknown, code: string, image: string) => {
uploads.push({ code, image });
return Promise.resolve({ id: `file-${code}` });
}),
});
return { service, uploads, saved };
};
const apply = (
service: ContractTransitionService,
dto: Record<string, unknown>,
) =>
(
service as unknown as {
applySignature(
c: unknown,
d: unknown,
o: { signerUserId?: string },
): Promise<void>;
}
).applySignature(contract, dto, { signerUserId: 'u-1' });
const staffDto = {
role: 'STAFF' as const,
signerDisplayName: 'E. Staff',
signatureImageBase64: 'data:image/png;base64,U0lH',
};
it('seals the EDR side with the global stamp', async () => {
const { service, uploads, saved } = build();
await apply(service, staffDto);
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
);
});
it('ignores a stamp a staff client tries to supply', async () => {
const { service, uploads } = 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==',
);
});
/**
* 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.
*/
it('refuses to counter-sign when no global stamp is configured', async () => {
const { service, saved } = build(null);
await expect(apply(service, staffDto)).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(apply(service, staffDto)).rejects.toThrow(/company stamp is configured/i);
expect(saved).toHaveLength(0);
});
it('requires the customer to upload their own stamp', async () => {
const { service, saved } = build();
await expect(
apply(service, {
role: 'CUSTOMER',
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,U0lH',
}),
).rejects.toThrow(/company stamp is required/i);
expect(saved).toHaveLength(0);
});
it('snapshots the customer stamp and never substitutes the global one', async () => {
const { service, uploads } = build();
const customerStamp = 'data:image/png;base64,Q1VTVA==';
await apply(service, {
role: 'CUSTOMER',
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,U0lH',
stampImageBase64: customerStamp,
});
expect(uploads).toContainEqual({
code: 'stamp_customer',
image: customerStamp,
});
expect(uploads.map((u) => u.image)).not.toContain(GLOBAL_STAMP);
});
/**
* DIRECTOR/CEO rows are internal approval signatures, not party seals, so
* they are deliberately exempt from the stamp requirement.
*/
it('lets internal approval signatures through without any stamp', async () => {
const { service, uploads, saved } = build();
await apply(service, {
role: 'DIRECTOR',
signerDisplayName: 'D. Director',
signatureImageBase64: 'data:image/png;base64,U0lH',
});
expect(uploads.map((u) => u.code)).toEqual(['signature_director']);
expect(saved[0]).toEqual(expect.objectContaining({ stampFileId: null }));
});
});

View File

@@ -35,6 +35,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
@@ -175,6 +176,7 @@ export class ContractTransitionService {
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
private readonly stampSettings: StampSettingsService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -1103,23 +1105,39 @@ export class ContractTransitionService {
// The company stamp is a separate image from the drawn signature. Both
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
// are internal approval signatures, not party seals, so they stay exempt.
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
if (stampRequired && !dto.stampImageBase64) {
//
// The two parties source their seal differently: the customer uploads their
// own company stamp, while EDR always seals with the ONE global stamp
// (StampSettingsService) — staff never upload or pick a stamp.
if (role === 'CUSTOMER' && !dto.stampImageBase64) {
throw new BadRequestException(
'A company stamp is required to sign this contract.',
);
}
// 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;
if (role === 'STAFF') {
stampImageBase64 = await this.stampSettings.getStampImageUrl();
if (!stampImageBase64) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
}
const fileRecord = await this.uploadSignatureAsset(
contract,
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = dto.stampImageBase64
const stampRecord = stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
stampImageBase64,
)
: null;