mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(companies): onboard co-operative unions and farms
They hold a TIN but no business licence, so there is no eTrade record to look their registration up in. A checkbox on the first wizard step marks them, and everything that assumed a trade licence bends around it: - The company step replaces the eTrade lookup with typed registration details — name, region, zone, woreda, kebele, house number — required exactly because they are now on screen. applyEtradeSourcedFields skips the lookup rather than failing it, so what the customer sends is what is stored. - No freight-forwarder role. Forwarding is licensed work, so the option is not offered, and the API refuses it at start-onboarding and at every later role-add rather than letting approval fail on a document they cannot produce. - No per-role business-licence upload, client-side or in the completion gate. - Their own document set (company_onboarding_documents_cooperative) merges on top of the nationality one, admin-managed like every other set. Nationality wins a fileKey collision so no slot renders twice, and the DARS paper is not injected into it — the set it merges onto already carries one. - The owner is typed in full; with no eTrade manager on file the licence comparison reports "nothing to compare against", which backoffice now explains rather than leaving as a bare dash. Stored as an attributes flag, not a column: everything it changes is behavioural, and nothing queries or joins on it.
This commit is contained in:
@@ -49,6 +49,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
@@ -221,6 +222,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
StampSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
SupportContentModule,
|
||||
OtpModule,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Single-row table holding the one company stamp/seal image stamped onto
|
||||
* generated invoice/receipt PDFs (see StampSettingsService /
|
||||
* InvoiceDocumentService). Same single-row shape as exchange_settings; the
|
||||
* app never inserts more than one row.
|
||||
*/
|
||||
export class StampSettings3400000000000 implements MigrationInterface {
|
||||
name = "StampSettings3400000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.stamp_settings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
stamp_file_id uuid REFERENCES freight.files(id),
|
||||
updated_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`);
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
import {
|
||||
PdfColor,
|
||||
@@ -53,6 +54,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 +71,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 +94,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 +239,10 @@ export class InvoiceDocumentService {
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
const sealMarkup = model.stampImageUrl
|
||||
? `<img src="${esc(model.stampImageUrl)}" alt="Company stamp" />`
|
||||
: esc(sealText);
|
||||
const sealClass = model.stampImageUrl ? "seal seal-image" : "seal";
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||
@@ -256,6 +281,8 @@ 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; }
|
||||
.seal.seal-image { border: none; border-radius: 0; opacity: 1; transform: none; }
|
||||
.seal img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.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 +310,7 @@ export class InvoiceDocumentService {
|
||||
Issued: ${esc(date(model.issuedAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="${sealClass}">${sealMarkup}</div>
|
||||
<div class="summary">${summaryRows}</div>
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -266,6 +266,7 @@ export class CompaniesController {
|
||||
dto.companyType,
|
||||
dto.roles,
|
||||
dto.nationality,
|
||||
dto.cooperative,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,6 +24,7 @@ import { FilesService } from "../files/files.service";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||
import {
|
||||
COOPERATIVE_ONBOARDING_CODE,
|
||||
POA_DELEGATION_FILE_KEY,
|
||||
POA_DELEGATION_LABEL,
|
||||
POA_DELEGATION_PENDING_CODE,
|
||||
@@ -60,6 +61,8 @@ import {
|
||||
CompanyNationality,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
COOPERATIVE_KEY,
|
||||
isCooperative,
|
||||
} from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
@@ -262,6 +265,7 @@ export class CompaniesService {
|
||||
: "company_onboarding_documents_ethiopian";
|
||||
}
|
||||
|
||||
|
||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
if (exists) {
|
||||
@@ -368,19 +372,41 @@ export class CompaniesService {
|
||||
companyType: CompanyType,
|
||||
roles: ProfileType[],
|
||||
nationality?: CompanyNationality,
|
||||
cooperative?: boolean,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
// Already started — reuse the existing draft, just ensure roles exist and
|
||||
// keep the nationality up to date if it was (re)selected.
|
||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||
if (existing) {
|
||||
const companyId = existing.company?.id ?? existing.companyId;
|
||||
// Only load the row when the answer actually depends on it: to merge the
|
||||
// flag into `attributes`, or to read a stored one the caller didn't send.
|
||||
const needsCompany =
|
||||
cooperative !== undefined ||
|
||||
roles.includes(ProfileType.freightForwarder);
|
||||
const current = needsCompany
|
||||
? await this.companiesRepo.findById(companyId)
|
||||
: null;
|
||||
this.assertRolesAllowedForCooperative(
|
||||
cooperative ?? isCooperative(current),
|
||||
roles,
|
||||
);
|
||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||
if (nationality) {
|
||||
await this.companiesRepo.update(companyId, { nationality });
|
||||
const updates: Partial<Company> = {};
|
||||
if (nationality) updates.nationality = nationality;
|
||||
if (cooperative !== undefined) {
|
||||
updates.attributes = {
|
||||
...(current?.attributes ?? {}),
|
||||
[COOPERATIVE_KEY]: cooperative,
|
||||
};
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.companiesRepo.update(companyId, updates);
|
||||
}
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
this.assertRolesAllowedForCooperative(cooperative === true, roles);
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
|
||||
@@ -393,6 +419,7 @@ export class CompaniesService {
|
||||
country: "Ethiopia",
|
||||
nationality: nationality ?? CompanyNationality.Ethiopian,
|
||||
status: CompanyStatus.Pending,
|
||||
...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}),
|
||||
});
|
||||
|
||||
await this.profilesRepo.create({
|
||||
@@ -410,6 +437,27 @@ export class CompaniesService {
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A co-operative union or farm cannot hold the freight-forwarder role.
|
||||
*
|
||||
* Forwarding is licensed work — the forwarder signs on other companies'
|
||||
* behalf, which is why the role carries a mandatory Power of Attorney and a
|
||||
* DARS delegation paper. A co-op is here precisely because it has no business
|
||||
* licence, so the role is refused at the door rather than left to fail later
|
||||
* at approval with a document it can never produce.
|
||||
*/
|
||||
private assertRolesAllowedForCooperative(
|
||||
cooperative: boolean,
|
||||
roles: ProfileType[],
|
||||
): void {
|
||||
if (!cooperative) return;
|
||||
if (roles.includes(ProfileType.freightForwarder)) {
|
||||
throw new BadRequestException(
|
||||
"A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the company's operational profiles with the roles the user has
|
||||
* selected: create the missing ones, drop the ones they deselected.
|
||||
@@ -1887,6 +1935,7 @@ export class CompaniesService {
|
||||
// without a Power of Attorney and its DARS paper — checked here so the
|
||||
// customer is told at the point of asking, not at review.
|
||||
if (type === ProfileType.freightForwarder) {
|
||||
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||
const asForwarder = this.withProfileType(company, type);
|
||||
this.assertIdentityVerified(asForwarder);
|
||||
await this.assertPoaDelegationSatisfied(
|
||||
@@ -1933,6 +1982,7 @@ export class CompaniesService {
|
||||
|
||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!created && type === ProfileType.freightForwarder) {
|
||||
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||
const asForwarder = this.withProfileType(company, type);
|
||||
this.assertIdentityVerified(asForwarder);
|
||||
await this.assertPoaDelegationSatisfied(
|
||||
@@ -1986,18 +2036,35 @@ export class CompaniesService {
|
||||
.filter((f) => !f.get(company))
|
||||
.map((f) => ({ key: f.key, label: f.label }));
|
||||
|
||||
// 2. Nationality-based company documents + which are already uploaded.
|
||||
// 2. Nationality-based company documents + which are already uploaded. A
|
||||
// co-operative adds its own set on top: it provides everything its
|
||||
// nationality demands, plus the papers standing in for the business licence
|
||||
// it does not hold.
|
||||
const cooperative = isCooperative(company);
|
||||
const documentSettingCode = this.documentSettingCodeFor(
|
||||
company.nationality,
|
||||
);
|
||||
const [setting, uploadedFiles] = await Promise.all([
|
||||
const [setting, coopSetting, uploadedFiles] = await Promise.all([
|
||||
this.fileUploadSettingsService
|
||||
.getByCode(documentSettingCode)
|
||||
.catch(() => null),
|
||||
cooperative
|
||||
? this.fileUploadSettingsService
|
||||
.getByCode(COOPERATIVE_ONBOARDING_CODE)
|
||||
.catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
this.filesService.findByResource(company.id, "companies"),
|
||||
]);
|
||||
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
|
||||
const documents = (setting?.fields ?? [])
|
||||
// The co-op set is admin-managed and could name a fileKey the nationality
|
||||
// set already carries; the nationality field wins so the same slot is never
|
||||
// rendered (or required) twice.
|
||||
const baseFields = setting?.fields ?? [];
|
||||
const baseKeys = new Set(baseFields.map((f) => f.fileKey));
|
||||
const documents = [
|
||||
...baseFields,
|
||||
...(coopSetting?.fields ?? []).filter((f) => !baseKeys.has(f.fileKey)),
|
||||
]
|
||||
.slice()
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
.map((f) => ({
|
||||
@@ -2029,7 +2096,13 @@ export class CompaniesService {
|
||||
};
|
||||
}),
|
||||
);
|
||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||
// A co-operative holds no business licence — that is the whole reason it
|
||||
// skips the eTrade lookup — so the per-role licence is not owed. Its own
|
||||
// document set (merged above) is what stands in for it. The profiles are
|
||||
// still reported so the portal can show them; only the requirement lifts.
|
||||
const missingLicenses = cooperative
|
||||
? []
|
||||
: licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
// 4. Power of Attorney. Whether there is one at all is the company's own
|
||||
// declaration — the question the wizard asks outright — and that answer is
|
||||
@@ -2115,7 +2188,7 @@ export class CompaniesService {
|
||||
const total =
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
licenseProfiles.length +
|
||||
(cooperative ? 0 : licenseProfiles.length) +
|
||||
poaItemCount +
|
||||
// The declaration and the verification it selects.
|
||||
2;
|
||||
@@ -2130,7 +2203,11 @@ export class CompaniesService {
|
||||
|
||||
return new OnboardingRequirementsResponseDto({
|
||||
documentSettingCode,
|
||||
cooperativeDocumentSettingCode: cooperative
|
||||
? COOPERATIVE_ONBOARDING_CODE
|
||||
: null,
|
||||
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||
cooperative,
|
||||
companyInfo: {
|
||||
complete: missingInfo.length === 0,
|
||||
missingFields: missingInfo,
|
||||
@@ -3369,6 +3446,13 @@ export class CompaniesService {
|
||||
company: Company,
|
||||
dto: UpdateProfileDto & { etradeManager?: { name: string; phone: string } },
|
||||
): Promise<void> {
|
||||
// A co-operative union or farm has a TIN but no business licence, so eTrade holds no
|
||||
// record to check these against — the customer types the company name and
|
||||
// the registered address themselves, and what they send IS the data. The
|
||||
// check is skipped rather than failed: running the lookup would 400 every
|
||||
// save with "no registration found for this TIN".
|
||||
if (isCooperative(company)) return;
|
||||
|
||||
const touched = ETRADE_SOURCED_FIELDS.some(
|
||||
(key) => key !== "tin" && dto[key] !== undefined,
|
||||
);
|
||||
|
||||
@@ -68,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: {
|
||||
@@ -106,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;
|
||||
@@ -86,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)) ??
|
||||
[];
|
||||
|
||||
@@ -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;
|
||||
@@ -110,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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,154 @@
|
||||
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 invoice PDFs. Never throws — invoice
|
||||
* generation must succeed even if the stamp lookup fails; callers fall back
|
||||
* to the programmatic seal when this returns null.
|
||||
*/
|
||||
async getStampImageUrl(): Promise<string | null> {
|
||||
try {
|
||||
const setting = await this.get();
|
||||
return await this.inlineImageUrl(setting.stampFile?.url);
|
||||
} 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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,31 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
// },
|
||||
// ];
|
||||
|
||||
/**
|
||||
* Extra documents a co-operative union or farm provides, merged on top of its
|
||||
* nationality set. It has a TIN but no business licence, so the papers that
|
||||
* evidence the co-operative itself stand in for the trade licence every other
|
||||
* company uploads.
|
||||
*
|
||||
* Only the registration certificate is seeded, and the set is admin-managed
|
||||
* like every other onboarding set — what these members must actually produce
|
||||
* is a backoffice decision, edited in the file-settings editor.
|
||||
*/
|
||||
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
{
|
||||
fileKey: "cooperative_registration_certificate",
|
||||
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
||||
helpText:
|
||||
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 50,
|
||||
displayOrder: 1,
|
||||
},
|
||||
];
|
||||
|
||||
interface OnboardingDocumentSetting {
|
||||
code: string;
|
||||
label: string;
|
||||
@@ -176,6 +201,14 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
|
||||
entity: "customer",
|
||||
fields: FOREIGN_ONBOARDING_FIELDS,
|
||||
},
|
||||
// Additive, not a nationality of its own: a union or farm still uploads
|
||||
// everything its nationality set demands, and these on top.
|
||||
{
|
||||
code: "company_onboarding_documents_cooperative",
|
||||
label: "Co-operative union / farm onboarding documents (additional)",
|
||||
entity: "customer",
|
||||
fields: COOPERATIVE_ONBOARDING_FIELDS,
|
||||
},
|
||||
// Legacy per-company-type codes — removed, unused by any resolver or portal
|
||||
// lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live).
|
||||
// {
|
||||
|
||||
@@ -1164,6 +1164,26 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:settings:dropdown:manage",
|
||||
"Manage dropdown settings",
|
||||
),
|
||||
perm(
|
||||
"b4b00002-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:settings:stamp:view",
|
||||
"View stamp settings",
|
||||
),
|
||||
perm(
|
||||
"b4b00002-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:settings:stamp:manage",
|
||||
"Manage stamp settings",
|
||||
),
|
||||
perm(
|
||||
"b4b00003-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:settings:invoice_stamp:view",
|
||||
"View invoice stamp settings",
|
||||
),
|
||||
perm(
|
||||
"b4b00003-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:settings:invoice_stamp:manage",
|
||||
"Manage invoice stamp settings",
|
||||
),
|
||||
perm(
|
||||
"b4c00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:audit:view",
|
||||
@@ -1849,6 +1869,16 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:dropdown:view",
|
||||
manage: "edr_freight_app:settings:dropdown:manage",
|
||||
},
|
||||
stamp: {
|
||||
view: "edr_freight_app:settings:stamp:view",
|
||||
manage: "edr_freight_app:settings:stamp:manage",
|
||||
},
|
||||
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
|
||||
// from `stamp` above, which is the per-employee approval-record teeter.
|
||||
invoiceStamp: {
|
||||
view: "edr_freight_app:settings:invoice_stamp:view",
|
||||
manage: "edr_freight_app:settings:invoice_stamp:manage",
|
||||
},
|
||||
exchangeRate: {
|
||||
view: "edr_freight_app:settings:exchange_rate:view",
|
||||
manage: "edr_freight_app:settings:exchange_rate:manage",
|
||||
|
||||
Reference in New Issue
Block a user