mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service";
|
||||
* Standalone document infrastructure — generic HTML→PDF plus the shared
|
||||
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
|
||||
* warehouses, …) can import it to print invoices without coupling to the
|
||||
* billing payment graph.
|
||||
* billing payment graph. StampSettingsService is @Global (see
|
||||
* StampSettingsModule) so InvoiceDocumentService can inject it without this
|
||||
* module declaring an explicit import.
|
||||
*/
|
||||
@Module({
|
||||
providers: [PdfRenderService, InvoiceDocumentService],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
|
||||
import {
|
||||
PdfColor,
|
||||
assembleSinglePagePdf,
|
||||
@@ -53,6 +55,13 @@ export interface InvoiceDocumentModel {
|
||||
totals: InvoiceDocumentTotal[];
|
||||
/** Override the round seal text; defaults from kind/status. */
|
||||
sealText?: string;
|
||||
/**
|
||||
* Company stamp image (data URL) to render instead of the plain text seal.
|
||||
* Callers normally leave this unset — `InvoiceDocumentService.render()`
|
||||
* fills it in from the single global stamp in StampSettingsService; set it
|
||||
* explicitly only to override that default for one document.
|
||||
*/
|
||||
stampImageUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,12 +72,21 @@ export interface InvoiceDocumentModel {
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvoiceDocumentService {
|
||||
constructor(private readonly pdf: PdfRenderService) {}
|
||||
constructor(
|
||||
private readonly pdf: PdfRenderService,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
) {}
|
||||
|
||||
async render(
|
||||
model: InvoiceDocumentModel,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const html = this.buildHtml(model);
|
||||
const stampImageUrl =
|
||||
model.stampImageUrl !== undefined
|
||||
? model.stampImageUrl
|
||||
: await this.stampSettings.getStampImageUrl();
|
||||
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl };
|
||||
|
||||
const html = this.buildHtml(resolvedModel);
|
||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||
@@ -77,7 +95,11 @@ export class InvoiceDocumentService {
|
||||
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
|
||||
// summary grid, line-item table, totals) from the model — not a flat
|
||||
// plain-text dump — so it still reads as a proper invoice document.
|
||||
fallback: () => this.buildFallbackPdf(model),
|
||||
// ponytail: still draws the plain vector seal, not the uploaded stamp
|
||||
// image — embedding a raster image needs a new PDF XObject primitive
|
||||
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
|
||||
// carry the real stamp too; today it's a rare degraded fallback.
|
||||
fallback: () => this.buildFallbackPdf(resolvedModel),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -218,6 +240,8 @@ export class InvoiceDocumentService {
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
const sealInner = sealMarkup(model.stampImageUrl, sealText);
|
||||
const sealCssClass = sealClass(model.stampImageUrl);
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||
@@ -256,6 +280,7 @@ export class InvoiceDocumentService {
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
${sealImageCss()}
|
||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||
@@ -283,7 +308,7 @@ export class InvoiceDocumentService {
|
||||
Issued: ${esc(date(model.issuedAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="${sealCssClass}">${sealInner}</div>
|
||||
<div class="summary">${summaryRows}</div>
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
|
||||
|
||||
/**
|
||||
* These three helpers are the single image-vs-text branch shared by every
|
||||
* EDR document's round seal, so a regression here silently unstamps invoices,
|
||||
* warehouse release papers and handover papers at once.
|
||||
*/
|
||||
describe("seal markup helpers", () => {
|
||||
const STAMP = "data:image/png;base64,QUJD";
|
||||
|
||||
describe("sealMarkup", () => {
|
||||
it("renders the stamp image when one is configured", () => {
|
||||
expect(sealMarkup(STAMP, ["EDR", "Warehouse"])).toBe(
|
||||
`<img src="${STAMP}" alt="Company stamp" />`,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to text rings when no stamp is configured", () => {
|
||||
expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe(
|
||||
"<span>EDR<br />Warehouse<br />Cleared</span>",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats undefined as unset", () => {
|
||||
expect(sealMarkup(undefined, "EDR")).toBe("<span>EDR</span>");
|
||||
});
|
||||
|
||||
it("accepts a bare string as a single line", () => {
|
||||
expect(sealMarkup(null, "EDR")).toBe("<span>EDR</span>");
|
||||
});
|
||||
|
||||
it("escapes text lines so document data cannot inject markup", () => {
|
||||
expect(sealMarkup(null, ['<script>alert("x")</script>'])).toBe(
|
||||
"<span><script>alert("x")</script></span>",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes the image src so it cannot break out of the attribute", () => {
|
||||
expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe(
|
||||
'<img src="data:image/png;base64,A" onerror="x" alt="Company stamp" />',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sealClass", () => {
|
||||
it("adds the image modifier only when stamped", () => {
|
||||
expect(sealClass(STAMP)).toBe("seal seal-image");
|
||||
expect(sealClass(null)).toBe("seal");
|
||||
});
|
||||
|
||||
it("honours a document's own seal selector", () => {
|
||||
expect(sealClass(STAMP, "sig-stamp-box")).toBe(
|
||||
"sig-stamp-box sig-stamp-box-image",
|
||||
);
|
||||
expect(sealClass(null, "sig-stamp-box")).toBe("sig-stamp-box");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sealImageCss", () => {
|
||||
it("neutralizes the drawn ring and rotation for a real stamp image", () => {
|
||||
const css = sealImageCss();
|
||||
|
||||
expect(css).toContain(".seal.seal-image { border: none;");
|
||||
expect(css).toContain("transform: none;");
|
||||
// The ::before pseudo-element draws the inner ring of the text seal.
|
||||
expect(css).toContain(".seal.seal-image::before { content: none; }");
|
||||
expect(css).toContain(".seal img { max-width: 100%;");
|
||||
});
|
||||
|
||||
it("scopes every rule to the given selector", () => {
|
||||
const css = sealImageCss("sig-stamp-box");
|
||||
|
||||
expect(css).not.toContain(".seal");
|
||||
expect(css).toContain(".sig-stamp-box.sig-stamp-box-image");
|
||||
expect(css).toContain(".sig-stamp-box img");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The single decision every EDR document makes about its round seal: draw the
|
||||
* one uploaded company stamp when one is configured (StampSettingsService), or
|
||||
* fall back to the plain text rings the document styles itself.
|
||||
*
|
||||
* Only the image-vs-text branch and the image overrides live here — each
|
||||
* document keeps its own `.seal` geometry (the invoice's seal is absolutely
|
||||
* positioned top-right, the warehouse papers' sit inline above the signature
|
||||
* lines), so centralizing the source of the stamp does not relayout anything.
|
||||
*
|
||||
* These helpers are for the HTML/Chromium render path. The hand-built vector
|
||||
* fallbacks in styled-pdf.util.ts cannot embed a raster image and continue to
|
||||
* draw their vector seal — see InvoiceDocumentService for that caveat.
|
||||
*/
|
||||
|
||||
/** Escape a value for interpolation into HTML text or a quoted attribute. */
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS overrides that neutralize a document's own ring/rotation styling when the
|
||||
* seal is a real stamp image. Append inside a document's <style> block, after
|
||||
* its own `.seal` rules. `selector` is the document's seal class ("seal").
|
||||
*/
|
||||
export function sealImageCss(selector = "seal"): string {
|
||||
return [
|
||||
`.${selector}.${selector}-image { border: none; border-radius: 0; opacity: 1; transform: none; }`,
|
||||
`.${selector}.${selector}-image::before { content: none; }`,
|
||||
`.${selector} img { max-width: 100%; max-height: 100%; object-fit: contain; }`,
|
||||
].join("\n ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner markup for the seal element: the stamp image, or the given text lines
|
||||
* wrapped in a <span> (matching the `.seal span { position: relative }` rule
|
||||
* the ring-drawing documents rely on).
|
||||
*
|
||||
* `stampImageUrl` is expected to be a data URL from
|
||||
* StampSettingsService.getStampImageUrl(); null renders the text fallback.
|
||||
*/
|
||||
export function sealMarkup(
|
||||
stampImageUrl: string | null | undefined,
|
||||
textLines: string | string[],
|
||||
): string {
|
||||
if (stampImageUrl) {
|
||||
return `<img src="${escapeHtml(stampImageUrl)}" alt="Company stamp" />`;
|
||||
}
|
||||
const lines = Array.isArray(textLines) ? textLines : [textLines];
|
||||
return `<span>${lines.map(escapeHtml).join("<br />")}</span>`;
|
||||
}
|
||||
|
||||
/** Class attribute for the seal element — adds the image modifier when stamped. */
|
||||
export function sealClass(
|
||||
stampImageUrl: string | null | undefined,
|
||||
selector = "seal",
|
||||
): string {
|
||||
return stampImageUrl ? `${selector} ${selector}-image` : selector;
|
||||
}
|
||||
@@ -304,6 +304,7 @@ export class BookingContractService {
|
||||
async getSignatures(bookingId: string) {
|
||||
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||||
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
|
||||
await this.viewModelBuilder.attachProviderStamp(views);
|
||||
await this.inlineSignatureImages(views);
|
||||
return { signatures: views };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ export class ContractSignatureDto {
|
||||
|
||||
@ApiPropertyOptional()
|
||||
signatureImageUrl?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Company seal beside the signature. Set for the EDR (STAFF) side from the single global company stamp; null for the client side.',
|
||||
})
|
||||
stampImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class SavedSignatureViewDto {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
CompleteIdentityVerificationDto,
|
||||
} from "./dto/complete-identity-verification.dto";
|
||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||
import { SetPoaDeclaredDto } from "./dto/set-poa-declared.dto";
|
||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||
import {
|
||||
@@ -265,6 +266,7 @@ export class CompaniesController {
|
||||
dto.companyType,
|
||||
dto.roles,
|
||||
dto.nationality,
|
||||
dto.cooperative,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
@@ -415,90 +417,31 @@ export class CompaniesController {
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
|
||||
"Bind a completed Fayda verification to the company's single identity. " +
|
||||
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
|
||||
"`subject` must match the company's PoA declaration — the representative when one is named, otherwise the owner. " +
|
||||
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
|
||||
})
|
||||
async completeIdentityVerification(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: CompleteIdentityVerificationDto,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.completeIdentityVerification(user.id, dto, {
|
||||
email: user.email,
|
||||
phoneNumber: user.phoneNumber,
|
||||
});
|
||||
return this.companiesService.completeIdentityVerification(user.id, dto);
|
||||
}
|
||||
|
||||
@Post("identity/gm/same-as-owner")
|
||||
@Patch("identity/poa-declared")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
|
||||
"Refused until the owner is Fayda-verified — there would be nothing proven to copy.",
|
||||
"Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified. " +
|
||||
'Answering "no" removes the representative entirely: their details, their verification, their passport number and the DARS delegation paper. ' +
|
||||
'Refused for a freight forwarder, which cannot operate without a representative (its answer is always "yes").',
|
||||
})
|
||||
async setGmSameAsOwner(
|
||||
async setPoaDeclared(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: SetPoaDeclaredDto,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.setGmSameAsOwner(user.id, {
|
||||
email: user.email,
|
||||
phoneNumber: user.phoneNumber,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete("identity/gm")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
|
||||
"Leaves the GM open to be verified in their own right, or typed where Fayda is optional.",
|
||||
})
|
||||
async clearGmIdentity(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.clearGmIdentity(user.id);
|
||||
}
|
||||
|
||||
@Post("identity/poa/same-as-owner")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Declare the Power of Attorney is the company's owner, copying the owner's identity across. " +
|
||||
"Waives the DARS delegation paper — nobody delegates to themselves. " +
|
||||
"Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.",
|
||||
})
|
||||
async setPoaSameAsOwner(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.setPoaSameAsOwner(user.id, {
|
||||
email: user.email,
|
||||
phoneNumber: user.phoneNumber,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete("identity/poa/same-as-owner")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " +
|
||||
"Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.",
|
||||
})
|
||||
async clearPoaSameAsOwner(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.clearPoaSameAsOwner(user.id);
|
||||
}
|
||||
|
||||
@Delete("identity/fayda/poa")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
|
||||
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
|
||||
})
|
||||
async removePoaIdentity(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.removePoaIdentity(user.id);
|
||||
return this.companiesService.setPoaDeclared(user.id, dto.declared);
|
||||
}
|
||||
|
||||
@Patch("onboarding-step")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -174,21 +174,26 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("waives the paper when the owner represents the company themselves", async () => {
|
||||
// Nobody delegates to themselves, so a self-declared PoA owes no DARS
|
||||
// paper — the representative's own details are still required.
|
||||
it('owes nothing when the company answered "no representative"', async () => {
|
||||
// "The owner represents the company themselves" is now expressed as the
|
||||
// declaration being "no" — there is no delegation, so no paper is due. The
|
||||
// representative's details are cleared with the answer, so there is nothing
|
||||
// left to evidence either.
|
||||
const { service } = makeService({
|
||||
attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true },
|
||||
attributes: { ...VERIFIED_IDENTITIES, poaDeclared: "no" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
service.updateProfile("user-1", {} as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("grants the forwarder role to a self-represented company with no paper", async () => {
|
||||
it("refuses the forwarder role without a paper, however it represents itself", async () => {
|
||||
// The self-representation waiver is gone: a freight forwarder signs on
|
||||
// other companies' behalf, so the delegation and the paper evidencing it
|
||||
// are non-negotiable.
|
||||
const { service } = makeService({
|
||||
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true },
|
||||
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaDeclared: "yes" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -196,7 +201,7 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("rejects a paper the reviewer sent back for correction", async () => {
|
||||
|
||||
@@ -26,7 +26,13 @@ function makeService(existing: ExistingProfile[]) {
|
||||
})),
|
||||
softDelete: jest.fn(async () => undefined),
|
||||
};
|
||||
const companiesRepo = { update: jest.fn(async () => null) };
|
||||
// `findById` is only consulted when the co-operative flag is in play (adding
|
||||
// a forwarder role, or setting the flag itself) — a plain company row is the
|
||||
// right answer for every case here.
|
||||
const companiesRepo = {
|
||||
update: jest.fn(async () => null),
|
||||
findById: jest.fn(async () => ({ id: "company-1", attributes: {} })),
|
||||
};
|
||||
const profilesRepo = {
|
||||
findByUserId: jest.fn(async () => ({
|
||||
id: "external-1",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,9 +23,18 @@ export const COMPANY_FIELD_LABELS: Record<string, string> = {
|
||||
contactPersonPhone: "Contact person phone",
|
||||
contactPersonEmail: "Contact person email",
|
||||
contactPersonPosition: "Contact person position",
|
||||
generalManagerName: "General manager name",
|
||||
generalManagerPhone: "General manager phone",
|
||||
generalManagerEmail: "General manager email",
|
||||
ownerName: "Owner name",
|
||||
ownerPhone: "Owner phone",
|
||||
ownerEmail: "Owner email",
|
||||
ownerPassportNumber: "Owner passport number",
|
||||
poaPassportNumber: "PoA passport number",
|
||||
poaDeclared: "Has a Power of Attorney",
|
||||
// Nothing writes these any more (the general manager was removed), but
|
||||
// revisions and change requests filed before that still carry them — without
|
||||
// the labels those rows render raw attribute keys to a reviewer.
|
||||
generalManagerName: "General manager name (retired)",
|
||||
generalManagerPhone: "General manager phone (retired)",
|
||||
generalManagerEmail: "General manager email (retired)",
|
||||
poaName: "PoA name",
|
||||
poaPhone: "PoA phone",
|
||||
poaEmail: "PoA email",
|
||||
|
||||
@@ -5,21 +5,61 @@ import { Company, CompanyNationality } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* The three people a company is verified through — its owner, its Power of
|
||||
* Attorney and its General Manager. The owner is the person the company's
|
||||
* existence is proven by; the other two are personnel it names.
|
||||
* The two people a company can be described through.
|
||||
*
|
||||
* The GM is very often the owner, which is what the portal's "same as owner"
|
||||
* copy is for: that path reuses the owner's verified identity outright rather
|
||||
* than asking the same human to verify twice.
|
||||
* The **owner** is whoever the eTrade TIN record names as the business's
|
||||
* manager. Not necessarily the legal owner — eTrade's `ManagerNameEng` is
|
||||
* simply the person on the licence — but that is the point: whoever the company
|
||||
* puts forward here has to match the eTrade record, and the backoffice check is
|
||||
* exactly that comparison (see `ownerMatchesEtrade`).
|
||||
*
|
||||
* The **Power of Attorney** is who the company delegates to act for it, when it
|
||||
* delegates at all.
|
||||
*
|
||||
* Exactly ONE of them is identity-verified, and which one is decided by the
|
||||
* company's own answer (see {@link PoaDeclaration}): the representative if
|
||||
* there is one, otherwise the owner. There is no general manager — the concept
|
||||
* was removed; it named who to talk to and gated nothing.
|
||||
*/
|
||||
export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const;
|
||||
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
||||
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||
|
||||
/**
|
||||
* The company's answer to "does anyone hold power of attorney for you?".
|
||||
*
|
||||
* Explicit rather than derived from "are any `poa*` keys set", because "no" is
|
||||
* an answer that moves the verification onto the owner, while *absent* is a
|
||||
* question the customer has not reached yet. Stored on `company.attributes`
|
||||
* under {@link POA_DECLARED_KEY}.
|
||||
*
|
||||
* A freight forwarder never gets to answer: it signs on other companies'
|
||||
* behalf, so a Power of Attorney (and the DARS paper evidencing it) is
|
||||
* non-negotiable. {@link readPoaDeclaration} forces "yes" for them, which is
|
||||
* why the declaration is read through that helper rather than off the blob.
|
||||
*/
|
||||
export const POA_DECLARATIONS = ["yes", "no"] as const;
|
||||
export type PoaDeclaration = (typeof POA_DECLARATIONS)[number];
|
||||
|
||||
/** `company.attributes` key holding the {@link PoaDeclaration}. */
|
||||
export const POA_DECLARED_KEY = "poaDeclared";
|
||||
|
||||
/**
|
||||
* `company.attributes` keys holding the eTrade record's own manager, captured
|
||||
* at lookup time.
|
||||
*
|
||||
* Kept apart from `ownerName`/`ownerPhone` — which are what the *company*
|
||||
* asserts, and what a Fayda verification overwrites — precisely so the two can
|
||||
* be compared. Storing only one value would leave the reviewer comparing the
|
||||
* owner field against itself.
|
||||
*/
|
||||
export const ETRADE_MANAGER_NAME_KEY = "etradeManagerName";
|
||||
export const ETRADE_MANAGER_PHONE_KEY = "etradeManagerPhone";
|
||||
|
||||
export class CompleteIdentityVerificationDto {
|
||||
@ApiProperty({
|
||||
enum: IDENTITY_SUBJECTS,
|
||||
description: "Which of the company's people this verification is for.",
|
||||
description:
|
||||
"Which of the company's people this verification is for. Must match the company's PoA declaration — the representative when one is named, the owner when not.",
|
||||
})
|
||||
@IsIn(IDENTITY_SUBJECTS)
|
||||
subject!: IdentitySubject;
|
||||
@@ -35,9 +75,11 @@ export class CompleteIdentityVerificationDto {
|
||||
state!: string;
|
||||
}
|
||||
|
||||
/** One person's verification state, as reported back to the portal. */
|
||||
/** One person's identity state, as reported back to the portal. */
|
||||
export class IdentityVerificationStateDto {
|
||||
@ApiProperty() verified!: boolean;
|
||||
@ApiProperty({ description: "True once a Fayda verification is bound." })
|
||||
verified!: boolean;
|
||||
|
||||
@ApiProperty({ nullable: true }) name!: string | null;
|
||||
@ApiProperty({ nullable: true }) phone!: string | null;
|
||||
@ApiProperty({ nullable: true }) email!: string | null;
|
||||
@@ -45,13 +87,11 @@ export class IdentityVerificationStateDto {
|
||||
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
|
||||
@ApiProperty({ nullable: true }) birthdate!: string | null;
|
||||
@ApiProperty({ nullable: true }) gender!: string | null;
|
||||
}
|
||||
|
||||
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
description:
|
||||
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
|
||||
"Typed passport number. Fayda is an Ethiopian national ID, so a foreign company proves the identity with either — this is the alternative, not an addition.",
|
||||
})
|
||||
passportNumber!: string | null;
|
||||
}
|
||||
@@ -59,44 +99,62 @@ export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
|
||||
export class CompanyIdentityStateDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
|
||||
"True for a foreign company: a typed passport number proves the identity just as a Fayda verification does. An Ethiopian company must use Fayda.",
|
||||
})
|
||||
faydaRequired!: boolean;
|
||||
passportAccepted!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
enum: POA_DECLARATIONS,
|
||||
nullable: true,
|
||||
description:
|
||||
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
|
||||
'Whether the company named a Power of Attorney. Null until the customer answers — which is itself an outstanding onboarding item, since the answer decides who verifies.',
|
||||
})
|
||||
passportRequired!: boolean;
|
||||
poaDeclared!: PoaDeclaration | null;
|
||||
|
||||
@ApiProperty({ type: OwnerIdentityStateDto })
|
||||
owner!: OwnerIdentityStateDto;
|
||||
@ApiProperty({
|
||||
enum: IDENTITY_SUBJECTS,
|
||||
nullable: true,
|
||||
description:
|
||||
"Who the company's single identity verification belongs to: the PoA when one is named, the owner when not. Null while the declaration is unanswered.",
|
||||
})
|
||||
subject!: IdentitySubject | null;
|
||||
|
||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||
owner!: IdentityVerificationStateDto;
|
||||
|
||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||
poa!: IdentityVerificationStateDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.",
|
||||
"True once the subject is proven — Fayda-verified, or carrying a passport number where that is accepted.",
|
||||
})
|
||||
poaSameAsOwner!: boolean;
|
||||
identityProven!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
type: IdentityVerificationStateDto,
|
||||
nullable: true,
|
||||
description:
|
||||
"General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.",
|
||||
"The manager named on the eTrade licence, captured at lookup. Null when eTrade returned none (ManagerNameEng is frequently blank).",
|
||||
})
|
||||
gm!: IdentityVerificationStateDto;
|
||||
etradeManagerName!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
description:
|
||||
"That manager's phone, normalized to E.164 and captured at the same lookup. Paired with the name so the portal can still tell that the owner's details came from eTrade after a refresh, when the live lookup result is long gone — without it a resumed wizard offers them back as typeable inputs.",
|
||||
})
|
||||
etradeManagerPhone!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
description:
|
||||
"Does the owner the company put forward match the person on the eTrade licence? This is the backoffice's check. Null when there is nothing to compare — no eTrade manager on file, or no owner name yet. Advisory, not a gate: eTrade's Latin transliteration and Fayda's rarely agree character-for-character, so a reviewer decides.",
|
||||
})
|
||||
ownerMatchesEtrade!: boolean | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.",
|
||||
})
|
||||
gmSameAsOwner!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
||||
"False while the declaration is unanswered or the subject is unproven. Field-level completeness (owner/PoA details, documents) is reported separately by the onboarding requirements.",
|
||||
})
|
||||
complete!: boolean;
|
||||
}
|
||||
@@ -105,26 +163,9 @@ export class CompanyIdentityStateDto {
|
||||
const PREFIX: Record<IdentitySubject, string> = {
|
||||
owner: "owner",
|
||||
poa: "poa",
|
||||
gm: "gm",
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed GM fields, kept in step with the Fayda-written ones.
|
||||
*
|
||||
* The GM predates this verification: its details are plain company columns
|
||||
* that three notifier services mail (booking-lifecycle, train-scheduling and
|
||||
* contract notifiers all read `company.generalManagerEmail`). A verification
|
||||
* therefore writes BOTH — the `gm*` attributes carry the proof, these carry
|
||||
* the value everything else already reads — and an unverified company keeps
|
||||
* showing whatever was typed before this existed.
|
||||
*/
|
||||
const GM_TYPED_KEYS = {
|
||||
name: "generalManagerName",
|
||||
email: "generalManagerEmail",
|
||||
phone: "generalManagerPhone",
|
||||
} as const;
|
||||
|
||||
/** company.attributes keys that together mean "a PoA was entered". */
|
||||
/** `company.attributes` keys that together mean "a representative was entered". */
|
||||
const POA_KEYS = [
|
||||
"poaName",
|
||||
"poaPhone",
|
||||
@@ -139,7 +180,7 @@ function stateFor(
|
||||
): IdentityVerificationStateDto {
|
||||
const p = PREFIX[subject];
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
const state: IdentityVerificationStateDto = {
|
||||
return {
|
||||
verified: Boolean(read(`${p}FaydaSub`)),
|
||||
name: read(`${p}Name`),
|
||||
phone: read(`${p}Phone`),
|
||||
@@ -148,88 +189,114 @@ function stateFor(
|
||||
verifiedAt: read(`${p}FaydaVerifiedAt`),
|
||||
birthdate: read(`${p}Birthdate`),
|
||||
gender: read(`${p}Gender`),
|
||||
};
|
||||
if (subject !== "gm" || state.verified) return state;
|
||||
|
||||
// Companies onboarded before the GM was verifiable have typed details and no
|
||||
// `gm*` attributes at all. Report those rather than a blank card — they are
|
||||
// still what the notifiers mail — leaving `verified` false so the portal
|
||||
// offers the upgrade instead of pretending the identity is proven.
|
||||
//
|
||||
// Only for such an unverified GM, which is the whole population this exists
|
||||
// for. Merging the typed columns into a *verified* manager's state would read
|
||||
// back the email the portal asked them to type when Fayda supplied none, and
|
||||
// the input offering it — keyed on that value being absent — would vanish the
|
||||
// moment it was saved, leaving a typo uncorrectable.
|
||||
return {
|
||||
...state,
|
||||
name: state.name ?? read(GM_TYPED_KEYS.name),
|
||||
email: state.email ?? read(GM_TYPED_KEYS.email),
|
||||
phone: state.phone ?? read(GM_TYPED_KEYS.phone),
|
||||
passportNumber: read(`${p}PassportNumber`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive both people's verification state from the company row.
|
||||
* The company's PoA declaration, or null when it hasn't answered yet.
|
||||
*
|
||||
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
|
||||
* renders from it, so the settings page and the onboarding wizard can never
|
||||
* disagree with the rule the API actually enforces.
|
||||
* A freight forwarder is never asked: it acts on other companies' behalf, so a
|
||||
* representative and the DARS paper behind them are mandatory. Forcing it here
|
||||
* — rather than only disabling the radio in the portal — is what stops a
|
||||
* forwarder role added *after* onboarding from inheriting an old "no".
|
||||
*/
|
||||
export function readPoaDeclaration(
|
||||
company: Pick<Company, "attributes" | "companyProfiles">,
|
||||
): PoaDeclaration | null {
|
||||
if (
|
||||
(company.companyProfiles ?? []).some(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
)
|
||||
) {
|
||||
return "yes";
|
||||
}
|
||||
const value = company.attributes?.[POA_DECLARED_KEY];
|
||||
if (value === "yes" || value === "no") return value;
|
||||
|
||||
// No explicit answer, but the company holds a representative's details —
|
||||
// so it has one, and owes everything a representative brings with them.
|
||||
//
|
||||
// Covers rows that predate the question (the migration derives the same way)
|
||||
// and any write that reaches the attributes without going through
|
||||
// `setPoaDeclared`. Without this, PoA details could be saved with the
|
||||
// delegation paper silently unowed. Safe against a genuine "no": answering
|
||||
// it clears these keys, so they cannot outlive the answer.
|
||||
return POA_KEYS.some((k) => (company.attributes?.[k] as string | undefined)?.trim())
|
||||
? "yes"
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two people's names refer to the same person, as far as a string can tell?
|
||||
*
|
||||
* Deliberately loose: eTrade returns uppercase Latin transliterations of
|
||||
* Amharic names and Fayda returns its own, so exact equality would flag almost
|
||||
* every company. Case, punctuation, extra whitespace and word ORDER are all
|
||||
* ignored — "ABEBE KEBEDE TESFA" and "Tesfa, Abebe Kebede" match. Anything
|
||||
* beyond that is the reviewer's call, which is why the verdict is advisory.
|
||||
*/
|
||||
export function ownerNameMatchesEtrade(
|
||||
ownerName: string | null | undefined,
|
||||
etradeName: string | null | undefined,
|
||||
): boolean | null {
|
||||
const words = (v: string | null | undefined) =>
|
||||
(v ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9ሀ-\s]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const a = words(ownerName);
|
||||
const b = words(etradeName);
|
||||
if (a.length === 0 || b.length === 0) return null;
|
||||
return a.length === b.length && a.every((w, i) => w === b[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the company's identity state from its row.
|
||||
*
|
||||
* Pure and shared: `CompaniesService` gates on it, `ProfileResponseDto` and the
|
||||
* backoffice's company DTO render from it, so the settings page, the onboarding
|
||||
* wizard and the reviewer can never disagree with the rule the API enforces.
|
||||
*/
|
||||
export function buildCompanyIdentityState(
|
||||
company: Company,
|
||||
): CompanyIdentityStateDto {
|
||||
const attrs = company.attributes ?? {};
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
|
||||
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
|
||||
// one, so a typed passport number is the mandatory credential there instead.
|
||||
// The two are mutually exclusive by nationality but independently tracked,
|
||||
// since a foreign owner verifying with Fayda doesn't waive the passport.
|
||||
const foreign = company.nationality === CompanyNationality.Foreign;
|
||||
const faydaRequired = !foreign;
|
||||
const passportRequired = foreign;
|
||||
// Fayda is an Ethiopian national ID. A foreign company's people may hold
|
||||
// none, so a typed passport number stands in — either one proves the person,
|
||||
// and holding both is fine.
|
||||
const passportAccepted = company.nationality === CompanyNationality.Foreign;
|
||||
|
||||
const owner: OwnerIdentityStateDto = {
|
||||
...stateFor(attrs, "owner"),
|
||||
passportNumber: read("ownerPassportNumber"),
|
||||
};
|
||||
const owner = stateFor(attrs, "owner");
|
||||
const poa = stateFor(attrs, "poa");
|
||||
const poaDue =
|
||||
(company.companyProfiles ?? []).some(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||
const poaDeclared = readPoaDeclaration(company);
|
||||
const subject: IdentitySubject | null =
|
||||
poaDeclared === "yes" ? "poa" : poaDeclared === "no" ? "owner" : null;
|
||||
|
||||
const gm = stateFor(attrs, "gm");
|
||||
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
|
||||
const poaSameAsOwner = Boolean(attrs.poaSameAsOwner);
|
||||
const proven = (s: IdentityVerificationStateDto) =>
|
||||
s.verified || (passportAccepted && Boolean(s.passportNumber?.trim()));
|
||||
|
||||
const ownerProven = faydaRequired
|
||||
? owner.verified
|
||||
: !passportRequired || Boolean(owner.passportNumber);
|
||||
const identityProven =
|
||||
subject === null ? false : proven(subject === "poa" ? poa : owner);
|
||||
|
||||
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||
// personnel can be held to it. A foreign company may nominate a
|
||||
// representative who holds one — and is offered the verification — but a
|
||||
// typed name has to remain sufficient, or a foreign company whose PoA has no
|
||||
// Fayda ID could never finish onboarding.
|
||||
const poaProven = faydaRequired
|
||||
? poa.verified
|
||||
: poa.verified || Boolean(poa.name?.trim());
|
||||
|
||||
// The GM is deliberately absent from this verdict: it names who to talk to,
|
||||
// not what the company may do, and it has never gated trading. Capturing it
|
||||
// through Fayda changes how it is collected, not whether it is required.
|
||||
const complete = ownerProven && (!poaDue || poaProven);
|
||||
const etradeManagerName =
|
||||
(attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null;
|
||||
const etradeManagerPhone =
|
||||
(attrs[ETRADE_MANAGER_PHONE_KEY] as string | undefined) ?? null;
|
||||
|
||||
return {
|
||||
faydaRequired,
|
||||
passportRequired,
|
||||
passportAccepted,
|
||||
poaDeclared,
|
||||
subject,
|
||||
owner,
|
||||
poa,
|
||||
poaSameAsOwner,
|
||||
gm,
|
||||
gmSameAsOwner,
|
||||
complete,
|
||||
identityProven,
|
||||
etradeManagerName,
|
||||
etradeManagerPhone,
|
||||
ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName),
|
||||
complete: subject !== null && identityProven,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* truth the wizard uses to auto-finish.
|
||||
*/
|
||||
|
||||
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
|
||||
import {
|
||||
CompanyIdentityStateDto,
|
||||
PoaDeclaration,
|
||||
} from "./complete-identity-verification.dto";
|
||||
|
||||
export interface OnboardingInfoField {
|
||||
key: string;
|
||||
@@ -38,15 +41,19 @@ export interface OnboardingLicenseProfile {
|
||||
}
|
||||
|
||||
export interface OnboardingPoaState {
|
||||
/** True when the company operates as a freight forwarder — PoA is mandatory. */
|
||||
required: boolean;
|
||||
/** True once any PoA detail has been entered. */
|
||||
provided: boolean;
|
||||
/**
|
||||
* True when the DARS delegation paper is owed — a PoA exists (or is
|
||||
* mandatory) and is not the owner themselves. An owner representing their own
|
||||
* company delegates to nobody, so there is no delegation to evidence.
|
||||
* True when the company operates as a freight forwarder: it signs on other
|
||||
* companies' behalf, so a Power of Attorney is non-negotiable and the portal
|
||||
* renders the question answered and locked rather than asking it.
|
||||
*/
|
||||
locked: boolean;
|
||||
/**
|
||||
* The company's answer to "does anyone hold power of attorney for you?".
|
||||
* Null until it answers — which is itself outstanding, since the answer
|
||||
* decides whose identity is verified.
|
||||
*/
|
||||
declared: PoaDeclaration | null;
|
||||
/** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */
|
||||
delegationLetterRequired: boolean;
|
||||
/** True when the DARS delegation paper is stored for the company. */
|
||||
delegationLetterUploaded: boolean;
|
||||
@@ -61,7 +68,19 @@ export interface OnboardingPoaState {
|
||||
export class OnboardingRequirementsResponseDto {
|
||||
/** Resolved document setting code (by nationality) the docs were drawn from. */
|
||||
documentSettingCode: string;
|
||||
/**
|
||||
* The co-operative document set, merged on top of the nationality one — null
|
||||
* for every other company. `documents` below already carries the merged
|
||||
* result; this is only so the portal can fetch the same extra fields when it
|
||||
* renders the pickers from the file-settings endpoint.
|
||||
*/
|
||||
cooperativeDocumentSettingCode: string | null;
|
||||
nationality: string;
|
||||
/**
|
||||
* The company trades as a co-operative: no business licence, so no eTrade
|
||||
* lookup, no per-role licence upload, and no freight-forwarder role.
|
||||
*/
|
||||
cooperative: boolean;
|
||||
|
||||
/** Required company-information fields and whether each is filled. */
|
||||
companyInfo: {
|
||||
@@ -99,7 +118,9 @@ export class OnboardingRequirementsResponseDto {
|
||||
|
||||
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
|
||||
this.documentSettingCode = init.documentSettingCode;
|
||||
this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode;
|
||||
this.nationality = init.nationality;
|
||||
this.cooperative = init.cooperative;
|
||||
this.companyInfo = init.companyInfo;
|
||||
this.documents = init.documents;
|
||||
this.licenseProfiles = init.licenseProfiles;
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
} from "./complete-identity-verification.dto";
|
||||
import { Company } from "../entities/company.entity";
|
||||
import { Company, isCooperative } from "../entities/company.entity";
|
||||
import { ExternalProfile } from "../entities/external-profile.entity";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
@@ -15,6 +15,12 @@ export class ProfileResponseDto {
|
||||
companyName: string;
|
||||
companyType: string;
|
||||
nationality: string | null;
|
||||
/**
|
||||
* The company trades as a co-operative: it has a TIN but no business licence,
|
||||
* so the company step collects the registration by hand instead of fetching
|
||||
* it from eTrade.
|
||||
*/
|
||||
cooperative: boolean;
|
||||
companyLocation: string;
|
||||
companyAddress: string | null;
|
||||
tinNumber: string;
|
||||
@@ -42,9 +48,10 @@ export class ProfileResponseDto {
|
||||
contactPersonPhone: string | null;
|
||||
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
|
||||
contactVerifiedPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
generalManagerEmail: string | null;
|
||||
generalManagerPhone: string | null;
|
||||
/** The owner — whoever the eTrade licence names as the business's manager. */
|
||||
ownerName: string | null;
|
||||
ownerEmail: string | null;
|
||||
ownerPhone: string | null;
|
||||
|
||||
poaName: string | null;
|
||||
poaPhone: string | null;
|
||||
@@ -55,12 +62,13 @@ export class ProfileResponseDto {
|
||||
profileId: string;
|
||||
|
||||
/**
|
||||
* Fayda verification state for the company's owner and PoA — not the general
|
||||
* manager, which is a separate typed role. The settings tabs and the
|
||||
* onboarding wizard render from `identity.faydaRequired` /
|
||||
* `identity.passportRequired`: an Ethiopian company verifies the owner (and
|
||||
* PoA) instead of typing their details; a foreign one requires a typed
|
||||
* passport number instead.
|
||||
* The company's single identity verification, plus who it belongs to.
|
||||
*
|
||||
* `identity.subject` follows the company's PoA declaration — the
|
||||
* representative when one is named, otherwise the owner. The settings tabs
|
||||
* and the onboarding wizard render from it: `passportAccepted` says whether a
|
||||
* typed passport number is an alternative to Fayda (foreign companies only),
|
||||
* and `ownerMatchesEtrade` is the check the backoffice makes.
|
||||
*/
|
||||
identity: CompanyIdentityStateDto;
|
||||
|
||||
@@ -84,6 +92,7 @@ export class ProfileResponseDto {
|
||||
this.companyName = company.name;
|
||||
this.companyType = company.type;
|
||||
this.nationality = company.nationality ?? null;
|
||||
this.cooperative = isCooperative(company);
|
||||
this.companyProfiles =
|
||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||
[];
|
||||
@@ -113,9 +122,9 @@ export class ProfileResponseDto {
|
||||
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
|
||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
||||
this.ownerName = attrs.ownerName ?? null;
|
||||
this.ownerEmail = attrs.ownerEmail ?? null;
|
||||
this.ownerPhone = attrs.ownerPhone ?? null;
|
||||
this.poaName = attrs.poaName ?? null;
|
||||
this.poaPhone = attrs.poaPhone ?? null;
|
||||
this.poaEmail = attrs.poaEmail ?? null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CompanyType,
|
||||
CompanyStatus,
|
||||
CompanyNationality,
|
||||
isCooperative,
|
||||
} from '../entities/company.entity';
|
||||
import {
|
||||
CompanyProfile,
|
||||
@@ -55,6 +56,12 @@ export class ResponseCompanyDto {
|
||||
type: CompanyType;
|
||||
status: CompanyStatus;
|
||||
nationality?: CompanyNationality | null;
|
||||
/**
|
||||
* The company trades as a co-operative: no business licence, so its
|
||||
* registration was typed rather than fetched from eTrade and there is no
|
||||
* eTrade manager to check the owner against.
|
||||
*/
|
||||
cooperative: boolean;
|
||||
tin: string;
|
||||
vatNumber?: string | null;
|
||||
fanNumber?: string | null;
|
||||
@@ -89,9 +96,14 @@ export class ResponseCompanyDto {
|
||||
houseNo?: string | null;
|
||||
|
||||
/**
|
||||
* Owner/PoA Fayda verification state, shared with the portal
|
||||
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
|
||||
* disagrees with — the rule the API actually enforces.
|
||||
* The company's single identity verification, shared with the portal
|
||||
* (`buildCompanyIdentityState`) so backoffice never re-derives — or disagrees
|
||||
* with — the rule the API actually enforces.
|
||||
*
|
||||
* `subject` names whose verification it is (the PoA when one is declared,
|
||||
* otherwise the owner), and `ownerMatchesEtrade` is the reviewer's check:
|
||||
* does the owner the company put forward match the manager on the eTrade
|
||||
* licence? Advisory — see the note on that field.
|
||||
*/
|
||||
identity: CompanyIdentityStateDto;
|
||||
|
||||
@@ -105,6 +117,7 @@ export class ResponseCompanyDto {
|
||||
this.type = company.type;
|
||||
this.status = company.status;
|
||||
this.nationality = company.nationality ?? null;
|
||||
this.cooperative = isCooperative(company);
|
||||
this.tin = company.tin;
|
||||
this.vatNumber = company.vatNumber;
|
||||
this.fanNumber = company.fanNumber;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn } from "class-validator";
|
||||
|
||||
import {
|
||||
POA_DECLARATIONS,
|
||||
PoaDeclaration,
|
||||
} from "./complete-identity-verification.dto";
|
||||
|
||||
export class SetPoaDeclaredDto {
|
||||
@ApiProperty({
|
||||
enum: POA_DECLARATIONS,
|
||||
description:
|
||||
'Whether anyone holds power of attorney for this company. "no" tears down any representative already recorded.',
|
||||
})
|
||||
@IsIn(POA_DECLARATIONS)
|
||||
declared!: PoaDeclaration;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
} from "class-validator";
|
||||
import { CompanyNationality, CompanyType } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
@@ -14,4 +20,15 @@ export class StartOnboardingDto {
|
||||
@IsOptional()
|
||||
@IsEnum(CompanyNationality)
|
||||
nationality?: CompanyNationality;
|
||||
|
||||
/**
|
||||
* The company trades as a co-operative: it holds a TIN but no business
|
||||
* licence, so there is no eTrade record to fetch its registration from.
|
||||
* Chosen on the same step as the nationality and the roles, because it
|
||||
* decides all three of what the next step asks for, which documents apply,
|
||||
* and which roles are even available (a co-op cannot freight-forward).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
cooperative?: boolean;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
MaxLength,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
Matches,
|
||||
} from "class-validator";
|
||||
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
|
||||
import { CompanyNationality } from "../entities/company.entity";
|
||||
@@ -36,20 +35,21 @@ export class UpdateProfileDto {
|
||||
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||
tin?: string;
|
||||
|
||||
// Ethiopian VAT registration numbers are 10 digits (the same shape as the
|
||||
// TIN), but some are issued with an 11th. Both portal forms enforce the same
|
||||
// range; without it here the API happily stored whatever a stale client sent,
|
||||
// and the two layers disagreed about what the column may hold.
|
||||
// No shape check. Ethiopian VAT numbers are usually 10 or 11 digits, but a
|
||||
// foreign company's is whatever its own tax authority issues — letters,
|
||||
// dashes and any length — and a co-operative's registration numbering does
|
||||
// not follow the trade-licence pattern either. The field is required (the
|
||||
// portal enforces non-blank) but its content is not ours to police.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" })
|
||||
@MaxLength(64)
|
||||
vatNumber?: string;
|
||||
|
||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||
// company's PoA (or its general manager), so it is derived from a completed
|
||||
// Fayda verification rather than typed. The global validation pipe runs with
|
||||
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
|
||||
// so — see CompaniesService.completeIdentityVerification.
|
||||
// `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would
|
||||
// have to come from a completed verification rather than be typed — and
|
||||
// Fayda's userinfo carries no national ID number, so nothing produces one.
|
||||
// The global validation pipe runs with forbidNonWhitelisted, so a client that
|
||||
// still sends it gets a 400 telling it so.
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -78,18 +78,31 @@ export class UpdateProfileDto {
|
||||
@IsValidPhone()
|
||||
contactVerifiedPhone?: string;
|
||||
|
||||
/**
|
||||
* The owner — whoever the eTrade licence names as the business's manager.
|
||||
*
|
||||
* All three are required before onboarding can be submitted, whatever their
|
||||
* source: the eTrade lookup prefills the name and phone, a Fayda
|
||||
* verification can supply all three, and the portal renders an input for
|
||||
* whatever neither did (eTrade returns no email at all, and Fayda's email
|
||||
* claim is optional, so that one is usually typed).
|
||||
*
|
||||
* Locked once a Fayda verification supplied them — see
|
||||
* `IDENTITY_OWNED_FIELDS` — but only field by field: a claim that came back
|
||||
* empty owns nothing and stays typeable.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
generalManagerName?: string;
|
||||
ownerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
generalManagerEmail?: string;
|
||||
ownerEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsValidPhone()
|
||||
generalManagerPhone?: string;
|
||||
ownerPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -113,15 +126,22 @@ export class UpdateProfileDto {
|
||||
poaAddress?: string;
|
||||
|
||||
/**
|
||||
* The owner's passport number — the identity credential for a foreign
|
||||
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
|
||||
* written or locked by a Fayda verification: still required even if the
|
||||
* owner also verifies.
|
||||
* Passport numbers — the alternative identity credential for a foreign
|
||||
* company, since Fayda is an Ethiopian national ID. Plain typed fields, never
|
||||
* written or locked by a Fayda verification.
|
||||
*
|
||||
* Only the one belonging to the company's declared identity subject matters:
|
||||
* the PoA's when a representative is named, the owner's otherwise. An
|
||||
* Ethiopian company is not offered either — it must use Fayda.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerPassportNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaPassportNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
|
||||
@@ -32,6 +32,25 @@ export enum CompanyNationality {
|
||||
Foreign = "foreign",
|
||||
}
|
||||
|
||||
/**
|
||||
* `attributes` key marking a co-operative union or farm.
|
||||
*
|
||||
* Such a company has a TIN but no business licence, so there is no eTrade record to
|
||||
* look its registration up in — the company name, registered address and the
|
||||
* owner are all typed instead of fetched, and the eTrade authenticity check is
|
||||
* skipped rather than failed. It is a flag rather than a column because
|
||||
* everything it changes is behavioural (which lookup runs, which documents
|
||||
* apply, which roles are offered); nothing queries or joins on it.
|
||||
*/
|
||||
export const COOPERATIVE_KEY = "cooperative";
|
||||
|
||||
/** Is this a co-operative union or farm (a TIN, but no business licence)? */
|
||||
export function isCooperative(
|
||||
company: Pick<Company, "attributes"> | null | undefined,
|
||||
): boolean {
|
||||
return company?.attributes?.[COOPERATIVE_KEY] === true;
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "companies" })
|
||||
@Index(["tin"])
|
||||
@Index(["type"])
|
||||
@@ -112,29 +131,11 @@ export class Company extends BaseEntity {
|
||||
})
|
||||
contactPersonPhone?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "general_manager_name",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
generalManagerName?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "general_manager_email",
|
||||
type: "varchar",
|
||||
length: 150,
|
||||
nullable: true,
|
||||
})
|
||||
generalManagerEmail?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "general_manager_phone",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: true,
|
||||
})
|
||||
generalManagerPhone?: string | null;
|
||||
// The general manager used to live here as three columns. It named who to
|
||||
// talk to, gated nothing, and nothing ever populated the columns — the write
|
||||
// path put the values in `attributes`. Removed in RemoveGeneralManager; the
|
||||
// company's people are now its owner (whoever the eTrade licence names) and
|
||||
// its Power of Attorney, both in `attributes`.
|
||||
|
||||
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
|
||||
website?: string | null;
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./interfaces/file-upload-settings.repository.interface";
|
||||
import {
|
||||
COMPANY_ONBOARDING_CODE_PREFIX,
|
||||
COOPERATIVE_ONBOARDING_CODE,
|
||||
POA_DELEGATION_FILE_KEY,
|
||||
poaDelegationField,
|
||||
} from "./poa-delegation.constants";
|
||||
@@ -56,6 +57,10 @@ export class FileUploadSettingsService {
|
||||
*/
|
||||
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
|
||||
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
|
||||
// The co-operative set is merged ON TOP of a nationality set that already
|
||||
// carries the paper; injecting it here too would hand the portal the same
|
||||
// slot twice.
|
||||
if (setting.code === COOPERATIVE_ONBOARDING_CODE) return setting;
|
||||
const fields = setting.fields ?? [];
|
||||
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@ export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
|
||||
/** Prefix of the setting codes the field is injected into. */
|
||||
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
|
||||
|
||||
/**
|
||||
* The co-operative onboarding set. Unlike the nationality sets it is ADDITIVE —
|
||||
* merged on top of the company's `_ethiopian`/`_foreign` set rather than
|
||||
* replacing it — which is why the delegation paper is not injected into it: the
|
||||
* set it is merged onto already carries one.
|
||||
*/
|
||||
export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`;
|
||||
|
||||
const POA_DELEGATION_HELP =
|
||||
"Delegation paper issued by the Documents Authentication and Registration " +
|
||||
"Service (DARS) delegating the representative named above. Upload the " +
|
||||
|
||||
@@ -12,8 +12,8 @@ import { DataSource, EntityManager } from "typeorm";
|
||||
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
|
||||
* path stores that value in the `attributes` jsonb and has never populated the
|
||||
* column, so every reader of it was silently falling through to `phone` anyway.
|
||||
* `companies.general_manager_email` is the same trap on the email side — see
|
||||
* {@link companyNotifyEmailExpr}.
|
||||
* The retired `general_manager_email` column was the same trap on the email
|
||||
* side — see {@link companyNotifyEmailExpr}.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -50,24 +50,30 @@ export function companyNotifyPhoneExpr(alias: string): string {
|
||||
* SQL expression for the company's notification address, given the joined `pc`
|
||||
* alias.
|
||||
*
|
||||
* `companies.email` alone is not enough: it is written from ONE place — a
|
||||
* Fayda-verified owner's email claim — so a foreign company, whose owner proves
|
||||
* identity by passport instead, never gets one. Readers papered over that with
|
||||
* `COALESCE(email, general_manager_email)`, but that column has the same problem
|
||||
* `contact_person_phone` has above: onboarding writes the value into the
|
||||
* `attributes` jsonb and nothing has ever populated the column, so the fallback
|
||||
* could not fire and the mail was dropped in silence.
|
||||
* `companies.email` is now the owner's email, written on every profile save
|
||||
* whether or not the owner verified with Fayda — and the owner's email is a
|
||||
* required onboarding field, so a company that finished onboarding has one.
|
||||
* (It used to be written ONLY for a Fayda-verified owner, which meant every
|
||||
* foreign company had none; the gap was papered over with a
|
||||
* `general_manager_email` leg that could never fire, because onboarding wrote
|
||||
* that value into `attributes` and nothing ever populated the column.)
|
||||
*
|
||||
* So: the company address, then the two the customer actually filled in during
|
||||
* onboarding, then the account that registered them — which always has one,
|
||||
* signup requires it. `NULLIF` because a blank jsonb key is not an address and
|
||||
* `COALESCE` would happily stop on it.
|
||||
* The `generalManagerEmail` attribute is still consulted, after the contact
|
||||
* person: the general manager was removed, but companies onboarded before that
|
||||
* may carry an address there and nowhere else. RemoveGeneralManager backfills
|
||||
* `companies.email` from it, so this is belt-and-braces for rows that migration
|
||||
* could not resolve.
|
||||
*
|
||||
* `NULLIF` because a blank jsonb key is not an address and `COALESCE` would
|
||||
* happily stop on it. The account that registered the company is the last
|
||||
* resort — signup guarantees it has one.
|
||||
*/
|
||||
export function companyNotifyEmailExpr(alias: string): string {
|
||||
return `COALESCE(
|
||||
NULLIF(${alias}.email, ''),
|
||||
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
|
||||
NULLIF(${alias}.attributes->>'ownerEmail', ''),
|
||||
NULLIF(${alias}.attributes->>'contactPersonEmail', ''),
|
||||
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
|
||||
NULLIF(pc.email, '')
|
||||
)`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateStampSettingDto {
|
||||
@ApiProperty({ description: "Stamp image as a base64 data URL (PNG/JPG)." })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
stampImageBase64!: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { FileRecord } from "../../files/entities/file.entity";
|
||||
|
||||
/**
|
||||
* Single-row table holding the one company stamp/seal image stamped onto
|
||||
* generated invoice/receipt PDFs (see InvoiceDocumentService). Mirrors the
|
||||
* exchange_settings single-row pattern — `get()` lazily creates the row, and
|
||||
* there is never more than one.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "stamp_settings" })
|
||||
export class StampSetting extends BaseEntity {
|
||||
@Column({ name: "stamp_file_id", type: "uuid", nullable: true })
|
||||
stampFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: "stamp_file_id" })
|
||||
stampFile?: FileRecord | null;
|
||||
|
||||
/** IAM user id of the last operator to set/clear the stamp. */
|
||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||
updatedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Delete, Get, Put } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { UpdateStampSettingDto } from "./dto/update-stamp-setting.dto";
|
||||
import { StampSettingsService } from "./stamp-settings.service";
|
||||
|
||||
@ApiTags("stamp-settings")
|
||||
@ApiBearerAuth()
|
||||
@Controller("stamp-settings")
|
||||
export class StampSettingsController {
|
||||
constructor(private readonly service: StampSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
|
||||
get() {
|
||||
return this.service.getView();
|
||||
}
|
||||
|
||||
@Put()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Replace the company stamp" })
|
||||
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({
|
||||
summary: "Clear the company stamp (invoices fall back to the plain seal)",
|
||||
})
|
||||
clear(@CurrentUser() user: TCurrentUser) {
|
||||
return this.service.clearStamp(user?.id ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { StampSetting } from "./entities/stamp-setting.entity";
|
||||
import { StampSettingsController } from "./stamp-settings.controller";
|
||||
import { StampSettingsRepository } from "./stamp-settings.repository";
|
||||
import { StampSettingsService } from "./stamp-settings.service";
|
||||
|
||||
/**
|
||||
* Global so DocumentsModule (invoice PDF rendering) can inject
|
||||
* {@link StampSettingsService} without pulling in a circular billing/warehouse
|
||||
* dependency — same reasoning as ExchangeSettingsModule.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([StampSetting]), FilesModule, MinioModule],
|
||||
controllers: [StampSettingsController],
|
||||
providers: [StampSettingsRepository, StampSettingsService],
|
||||
exports: [StampSettingsService],
|
||||
})
|
||||
export class StampSettingsModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
|
||||
import { StampSetting } from "./entities/stamp-setting.entity";
|
||||
|
||||
@Injectable()
|
||||
export class StampSettingsRepository extends BaseRepository<StampSetting> {
|
||||
constructor(
|
||||
@InjectRepository(StampSetting)
|
||||
repo: Repository<StampSetting>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The single settings row, with its stamp file joined, or null before first upload. */
|
||||
findSingleton(): Promise<StampSetting | null> {
|
||||
return this.repository.findOne({ where: {}, relations: ["stampFile"] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { StampSettingsService } from "./stamp-settings.service";
|
||||
|
||||
/**
|
||||
* The one global company stamp feeds three document paths (invoices, warehouse
|
||||
* papers, contract signature blocks). All three treat the returned value as a
|
||||
* `data:` URL — so the data-URL-or-null contract of getStampImageUrl is what
|
||||
* these specs pin down, especially its behaviour when MinIO cannot be reached.
|
||||
*/
|
||||
describe("StampSettingsService.getStampImageUrl", () => {
|
||||
const PNG = Buffer.from("fake-png-bytes");
|
||||
const OBJECT_URL = "https://minio.local:9000/edr-freight/stamp/company.png";
|
||||
|
||||
const build = (
|
||||
overrides: {
|
||||
stampUrl?: string | null;
|
||||
getFileStream?: jest.Mock;
|
||||
findSingleton?: jest.Mock;
|
||||
} = {},
|
||||
) => {
|
||||
const service = Object.create(
|
||||
StampSettingsService.prototype,
|
||||
) as StampSettingsService;
|
||||
const warn = jest.fn();
|
||||
Object.assign(service, {
|
||||
logger: { warn, log: jest.fn() },
|
||||
repository: {
|
||||
findSingleton:
|
||||
overrides.findSingleton ??
|
||||
jest.fn().mockResolvedValue({
|
||||
id: "s-1",
|
||||
stampFileId: overrides.stampUrl ? "f-1" : null,
|
||||
stampFile: overrides.stampUrl ? { url: overrides.stampUrl } : null,
|
||||
updatedById: null,
|
||||
updatedAt: null,
|
||||
}),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
minioService: {
|
||||
getObjectNameFromUrl: jest.fn().mockReturnValue("stamp/company.png"),
|
||||
getFileStream:
|
||||
overrides.getFileStream ??
|
||||
jest.fn().mockResolvedValue(Readable.from(PNG)),
|
||||
},
|
||||
filesService: { upload: jest.fn() },
|
||||
});
|
||||
return { service, warn };
|
||||
};
|
||||
|
||||
it("inlines the stored stamp as a data URL", async () => {
|
||||
const { service } = build({ stampUrl: OBJECT_URL });
|
||||
|
||||
await expect(service.getStampImageUrl()).resolves.toBe(
|
||||
`data:image/png;base64,${PNG.toString("base64")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no stamp is configured", async () => {
|
||||
const { service } = build({ stampUrl: null });
|
||||
|
||||
await expect(service.getStampImageUrl()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("passes an already-inlined data URL straight through", async () => {
|
||||
const dataUrl = "data:image/png;base64,QUJD";
|
||||
const { service } = build({ stampUrl: dataUrl });
|
||||
|
||||
await expect(service.getStampImageUrl()).resolves.toBe(dataUrl);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: inlineImageUrl falls back to the raw object URL when MinIO is
|
||||
* unreachable, which is right for getView (a browser can fetch it) but wrong
|
||||
* here. ContractTransitionService base64-decodes this value to snapshot the
|
||||
* seal — and a URL decodes to garbage bytes WITHOUT throwing, so a transient
|
||||
* MinIO failure used to seal an executed contract with a corrupt image file.
|
||||
* Degrade to null instead so callers draw their text/vector seal.
|
||||
*/
|
||||
it("returns null rather than a raw object URL when MinIO inlining fails", async () => {
|
||||
const { service, warn } = build({
|
||||
stampUrl: OBJECT_URL,
|
||||
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
|
||||
});
|
||||
|
||||
await expect(service.getStampImageUrl()).resolves.toBeNull();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/plain seal/i));
|
||||
});
|
||||
|
||||
it("never throws when the settings lookup itself fails", async () => {
|
||||
const { service, warn } = build({
|
||||
findSingleton: jest.fn().mockRejectedValue(new Error("db is down")),
|
||||
});
|
||||
|
||||
await expect(service.getStampImageUrl()).resolves.toBeNull();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("db is down"));
|
||||
});
|
||||
|
||||
it("still exposes the raw URL through getView, which a browser can load", async () => {
|
||||
const { service } = build({
|
||||
stampUrl: OBJECT_URL,
|
||||
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
|
||||
});
|
||||
|
||||
await expect(service.getView()).resolves.toEqual(
|
||||
expect.objectContaining({ stampImageUrl: OBJECT_URL }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
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";
|
||||
|
||||
export interface StampSettingView {
|
||||
stampImageUrl: string | null;
|
||||
updatedById: string | null;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the single `stamp_settings` row: the one company stamp/seal image used
|
||||
* on generated invoice/receipt PDFs (see InvoiceDocumentService). Same
|
||||
* single-row shape as ExchangeSettingsService, but the value is an uploaded
|
||||
* image (via FilesService) rather than a scalar.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StampSettingsService {
|
||||
private readonly logger = new Logger(StampSettingsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: StampSettingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** The settings row, created empty on first access. */
|
||||
async get(): Promise<StampSetting> {
|
||||
const existing = await this.repository.findSingleton();
|
||||
if (existing) return existing;
|
||||
return this.repository.create({ stampFileId: null, updatedById: null });
|
||||
}
|
||||
|
||||
/** Current stamp, with the image inlined as a data URL (or null if unset). */
|
||||
async getView(): Promise<StampSettingView> {
|
||||
const setting = await this.get();
|
||||
return {
|
||||
stampImageUrl: await this.inlineImageUrl(setting.stampFile?.url),
|
||||
updatedById: setting.updatedById ?? null,
|
||||
updatedAt: setting.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamp image for embedding into generated documents, ALWAYS as a
|
||||
* `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.
|
||||
*/
|
||||
async getStampImageUrl(): Promise<string | null> {
|
||||
try {
|
||||
const setting = await this.get();
|
||||
const inlined = await this.inlineImageUrl(setting.stampFile?.url);
|
||||
if (inlined && !inlined.startsWith("data:")) {
|
||||
this.logger.warn(
|
||||
`Company stamp could not be inlined for document rendering (falling back to the plain seal): ${inlined}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return inlined;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not load company stamp for PDF rendering: ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the stamp image, storing it in MinIO via FilesService. */
|
||||
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,
|
||||
resource: "stamp_settings",
|
||||
code: "stamp",
|
||||
file: this.toUploadFile(stampImageBase64),
|
||||
uploadedByUserId: updatedById ?? null,
|
||||
});
|
||||
|
||||
await this.repository.update(current.id, {
|
||||
stampFileId: fileRecord.id,
|
||||
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). */
|
||||
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();
|
||||
}
|
||||
|
||||
private toUploadFile(base64: string): Express.Multer.File {
|
||||
const raw = base64.includes(",") ? base64.split(",")[1]! : base64;
|
||||
const buffer = Buffer.from(raw, "base64");
|
||||
return {
|
||||
fieldname: "stamp",
|
||||
originalname: "company-stamp.png",
|
||||
encoding: "7bit",
|
||||
mimetype: "image/png",
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: "",
|
||||
filename: "",
|
||||
path: "",
|
||||
};
|
||||
}
|
||||
|
||||
private async inlineImageUrl(url?: string | null): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
if (url.startsWith("data:")) return url;
|
||||
try {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
return `data:image/png;base64,${buffer.toString("base64")}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3329,6 +3329,12 @@ export class TrainSchedulingService {
|
||||
* Every export booking being confirmed loaded must already be received at the
|
||||
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
|
||||
* is the check that the cargo is physically in the yard before we call it loaded.
|
||||
*
|
||||
* Direct truck-to-train (exportHandoverMode = DIRECT_TO_TRAIN) is excluded —
|
||||
* that cargo is manually loaded from the customer's truck straight onto the
|
||||
* wagon, never sees the warehouse, and is never GRN'd. Its custody is attested
|
||||
* by the carriage acceptance sheet instead (same carve-out as the shared
|
||||
* assertExportReceivedWithGrn gate — see common/export-received-gate.ts).
|
||||
*/
|
||||
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
|
||||
if (!bookingIds.length) return;
|
||||
@@ -3337,6 +3343,7 @@ export class TrainSchedulingService {
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = ANY($1)
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.export_handover_mode IS DISTINCT FROM 'DIRECT_TO_TRAIN'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory inv
|
||||
WHERE inv.booking_id = b.id
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
primaryContactUserJoin,
|
||||
} from '../notifications/resolve-company-phone.util';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
|
||||
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
@@ -414,6 +416,7 @@ export class WarehouseInventoryService {
|
||||
private readonly handover: HandoverService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -3744,6 +3747,7 @@ export class WarehouseInventoryService {
|
||||
const html = this.buildReleaseDocumentHtml({
|
||||
reference,
|
||||
issuedAt,
|
||||
stampImageUrl: await this.stampSettings.getStampImageUrl(),
|
||||
bookingReference,
|
||||
bookingStatus: row?.bookingStatus ?? null,
|
||||
customerName: row?.customerName ?? null,
|
||||
@@ -4798,6 +4802,7 @@ export class WarehouseInventoryService {
|
||||
const html = this.buildHandoverDocumentHtml({
|
||||
reference,
|
||||
handedOverAt,
|
||||
stampImageUrl: await this.stampSettings.getStampImageUrl(),
|
||||
bookingReference,
|
||||
bookingStatus: row.bookingStatus ?? null,
|
||||
customerName: row.customerName ?? null,
|
||||
@@ -5603,6 +5608,8 @@ export class WarehouseInventoryService {
|
||||
truckType?: string | null;
|
||||
truckGateOut?: string | null;
|
||||
truckWeightTons?: number | null;
|
||||
/** The one global company stamp; null falls back to the drawn text seal. */
|
||||
stampImageUrl?: string | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -5689,6 +5696,7 @@ export class WarehouseInventoryService {
|
||||
.seal { width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
|
||||
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
|
||||
.seal span { position: relative; }
|
||||
${sealImageCss()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -5722,7 +5730,7 @@ export class WarehouseInventoryService {
|
||||
</div>
|
||||
<div class="signatures">
|
||||
<div class="line">Officer in charge name / signature / date</div>
|
||||
<div class="seal"><span>EDR<br />Warehouse<br />Cleared</span></div>
|
||||
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Cleared'])}</div>
|
||||
<div class="line">Customer or driver name / signature / date</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5762,6 +5770,8 @@ export class WarehouseInventoryService {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl: string | null;
|
||||
} | null;
|
||||
/** The one global company stamp; null falls back to the drawn text seal. */
|
||||
stampImageUrl?: string | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -5839,6 +5849,7 @@ export class WarehouseInventoryService {
|
||||
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
|
||||
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
|
||||
.seal span { position: relative; }
|
||||
${sealImageCss()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -5882,7 +5893,7 @@ export class WarehouseInventoryService {
|
||||
</div>
|
||||
<div class="signatures">
|
||||
<div class="line">Officer in charge name / signature / date</div>
|
||||
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
|
||||
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Handover'])}</div>
|
||||
<div class="line">
|
||||
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
|
||||
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>
|
||||
|
||||
Reference in New Issue
Block a user