diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c1b890719..029f2040b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts new file mode 100644 index 000000000..9556318f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts index c320a5d44..363cda3f0 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 06d164bbd..72146491d 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -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 + ? `Company stamp` + : esc(sealText); + const sealClass = model.stampImageUrl ? "seal seal-image" : "seal"; const summaryRows = model.summary .map((row) => `
${esc(row.label)}${esc(row.value)}
`) @@ -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))} -
${esc(sealText)}
+
${sealMarkup}
${summaryRows}
diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index a0c609f67..d35400f1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -266,6 +266,7 @@ export class CompaniesController { dto.companyType, dto.roles, dto.nationality, + dto.cooperative, ); return new CompanyInfoResponseDto(profile, company); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts index 5961be90f..ea81e4dbe 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -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", diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 4362c4285..cf2f2e84d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { 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 = {}; + 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 { + // 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, ); diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index b1baa2553..8d47dbb4e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -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) { 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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index dc2ddc4c3..0d7293ab0 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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)) ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index b78925285..e75182889 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts index 7687faab1..91fcb44a1 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 2ecfa3deb..54c36bf41 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -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 | null | undefined, +): boolean { + return company?.attributes?.[COOPERATIVE_KEY] === true; +} + @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"]) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 9651d4f37..21ddc4c91 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts index 7f8a34175..e8d593ded 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -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 " + diff --git a/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts b/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts new file mode 100644 index 000000000..96f86bf61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts b/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts new file mode 100644 index 000000000..7ab7a0e2c --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts new file mode 100644 index 000000000..f03d55058 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts new file mode 100644 index 000000000..6c9fc3a36 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts new file mode 100644 index 000000000..0ca4cfb68 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts @@ -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 { + constructor( + @InjectRepository(StampSetting) + repo: Repository, + ) { + super(repo); + } + + /** The single settings row, with its stamp file joined, or null before first upload. */ + findSingleton(): Promise { + return this.repository.findOne({ where: {}, relations: ["stampFile"] }); + } +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts new file mode 100644 index 000000000..01352e133 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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))); + }); + } +} diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 512229706..abc4e613c 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -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). // { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 06511cb2b..fb9afb5e1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ec63fa53c..58b8286f0 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -51,6 +51,8 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature"; +import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; @@ -783,6 +785,24 @@ const App = () => { } /> + + + + } + /> + + + + } + /> , permission: FREIGHT_PERMS.settings.dropdown.view, }, + { + label: "Stamp settings", + href: "/dashboard/stamp-settings", + icon: , + permission: FREIGHT_PERMS.settings.stamp.view, + }, + { + label: "Invoice stamp", + href: "/dashboard/invoice-stamp-settings", + icon: , + permission: FREIGHT_PERMS.settings.invoiceStamp.view, + }, { label: "Contract templates", href: "/dashboard/contract-templates", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts new file mode 100644 index 000000000..9d1594602 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts @@ -0,0 +1,45 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { stampSettingsService } from "@/services/stampSettings.service"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; + +const QUERY_KEY = ["stampSettings"]; + +export const useStampSettingsQuery = () => + useQuery({ + queryKey: QUERY_KEY, + queryFn: () => stampSettingsService.get(), + }); + +export const useSetStamp = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: (stampImageBase64: string) => + stampSettingsService.set(stampImageBase64), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("stampSettings.updated", "Company stamp updated")); + }, + onError: handleError, + }); +}; + +export const useClearStamp = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: () => stampSettingsService.clear(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("stampSettings.cleared", "Company stamp removed")); + }, + onError: handleError, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 09790091d..932604ce3 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -315,6 +315,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", diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index f197e1952..a6367786a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -822,6 +822,16 @@ export default function CustomerDetailPage() { : undefined } /> + {/* Why this company's registration was typed rather than + fetched, and why it carries no business licence. */} + @@ -945,6 +955,11 @@ export default function CustomerDetailPage() { > Matches the eTrade licence + ) : company.cooperative ? ( + + A co-operative union or farm holds no trade licence, so + there is no eTrade record to check the owner against. + ) : ( No eTrade manager name on file to compare against. diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx new file mode 100644 index 000000000..407218d70 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { Button } from "@/shared/common/ui/button"; +import { Save, Trash2 } from "lucide-react"; + +import { StampUpload } from "@/components/contracts/StampUpload"; +import { + useClearStamp, + useSetStamp, + useStampSettingsQuery, +} from "@/hooks/useStampSettings"; + +/** + * The one company stamp/seal stamped onto every generated invoice/receipt + * PDF (InvoiceDocumentService). Single global image — no per-employee choice. + */ +export default function InvoiceStampSettingsPage() { + const { data, isLoading } = useStampSettingsQuery(); + const setStamp = useSetStamp(); + const clearStamp = useClearStamp(); + const [draft, setDraft] = useState(null); + + useEffect(() => { + setDraft(null); + }, [data?.stampImageUrl]); + + const value = draft !== null ? draft : (data?.stampImageUrl ?? null); + const dirty = draft !== null && draft !== data?.stampImageUrl; + + const handleSave = async () => { + if (!draft) return; + await setStamp.mutateAsync(draft); + }; + + const handleClear = async () => { + if (!data?.stampImageUrl) return; + await clearStamp.mutateAsync(); + }; + + return ( +
+ + + Invoice stamp + + Stamped onto every generated invoice and receipt PDF. Replacing it + here changes it everywhere at once — there is no per-invoice or + per-user choice. + + + + + +
+ + {data?.stampImageUrl && !dirty && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx index 3035d58c7..a6855f540 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx @@ -609,10 +609,18 @@ const UploadTeeterAndSignature = () => { )} - {/* Teeter Tab */} + {/* Teeter Tab — single active stamp only: remove the current one to upload a replacement. */} {teeters.length > 0 && (
+ {teeters.length > 1 && ( +

+ {t( + "signatureUpload.multipleStampsWarning", + "Only one stamp is allowed. Remove the extras below to keep a single active stamp.", + )} +

+ )} {teeters.map(({ id, url }) => (

@@ -635,6 +643,7 @@ const UploadTeeterAndSignature = () => {

)} + {teeters.length === 0 && (
{!stampBlocks && !showLanguagePicker && (
+ )} diff --git a/apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts new file mode 100644 index 000000000..c88d4e08b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts @@ -0,0 +1,31 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import type { ApiResponse } from "@/types/apiResponse"; + +const BASE = "/stamp-settings"; + +/** Company stamp/seal used on generated invoice/receipt PDFs. */ +export interface StampSettings { + stampImageUrl: string | null; + updatedById: string | null; + updatedAt: string | null; +} + +export const stampSettingsService = { + get: async (): Promise => { + const response = await client.get>(BASE); + return unwrap(response.data); + }, + + set: async (stampImageBase64: string): Promise => { + const response = await client.put>(BASE, { + stampImageBase64, + }); + return unwrap(response.data); + }, + + clear: async (): Promise => { + const response = await client.delete>(BASE); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 4fd920916..87a71bf6e 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -222,6 +222,12 @@ export interface Company { fanNumber?: string | null; country: string; nationality?: CompanyNationality | null; + /** + * A co-operative union or farm: a TIN but no trade licence, so its + * registration was typed rather than fetched from eTrade, there is no eTrade + * manager to check the owner against, and it holds no freight-forwarder role. + */ + cooperative?: boolean; address?: string | null; phone?: string | null; email?: string | null; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index b9fdca622..6d75e502f 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,6 +1,7 @@ import { Box, Button, + Checkbox, Group, Modal, ScrollArea, @@ -164,6 +165,15 @@ export default function OnboardingWizardDialog({ const [roles, setRoles] = useState( existingProfiles.map((p) => p.type), ); + const [cooperative, setCooperative] = useState( + company?.company?.attributes?.cooperative === true, + ); + // Ticking the box drops a role the company can no longer hold, rather than + // letting Continue fail on a selection the API refuses. + const handleCooperativeChange = useCallback((checked: boolean) => { + setCooperative(checked); + if (checked) setRoles((prev) => prev.filter((r) => r !== "freight_forwarder")); + }, []); const [documentFiles, setDocumentFiles] = useState< Record >({}); @@ -214,6 +224,7 @@ export default function OnboardingWizardDialog({ companyType: string; roles: ProfileTypeValue[]; nationality?: CompanyNationality; + cooperative?: boolean; }) => api.companies.startOnboarding.call(vars), onSuccess: async () => { // Nationality drives the server-resolved identity requirements (Fayda vs @@ -296,6 +307,7 @@ export default function OnboardingWizardDialog({ resumedRef.current = true; setRoles(existingProfiles.map((p) => p.type)); setNationality(savedNationality); + setCooperative(company?.company?.attributes?.cooperative === true); // Resume into the form only when profiles exist; otherwise send the user to // role selection so the missing operational profiles get created. setPhase(hasOperationalProfiles ? "form" : "nationality-role"); @@ -310,8 +322,9 @@ export default function OnboardingWizardDialog({ companyType: companyTypeForRoles(roles), roles: roles as ProfileTypeValue[], nationality: nationality ?? undefined, + cooperative, }); - }, [roles, nationality, startMutation]); + }, [roles, nationality, cooperative, startMutation]); // Back from the form's first step returns to nationality/role selection. // Safe to re-enter: startOnboarding is idempotent — it reuses the existing @@ -463,6 +476,15 @@ export default function OnboardingWizardDialog({ // mandatory for an Ethiopian company; a foreign one may instead type a // passport number for the same person. identity: requirementsQuery.data?.identity, + // Server-confirmed, not the local checkbox: the flag is only real once + // startOnboarding has persisted it, and the form's whole company step + // branches on it. + cooperative: requirementsQuery.data?.cooperative ?? cooperative, + extraDocumentSettingCode: + requirementsQuery.data?.cooperativeDocumentSettingCode ?? null, + // A freight forwarder cannot answer the power-of-attorney question — the + // API forces "yes" — so the step offers no way to change it. + declarationLocked: requirementsQuery.data?.poa?.locked ?? false, onIdentityChange: () => { void profileQuery.refetch(); void requirementsQuery.refetch(); @@ -530,6 +552,16 @@ export default function OnboardingWizardDialog({ onChange={setNationality} embedded /> + {/* A co-operative union or farm registers on a TIN alone. It + changes what the next step asks for (typed registration, no + eTrade lookup), which documents apply, and which roles are on + offer — so it is answered here, alongside the other two. */} + handleCooperativeChange(e.currentTarget.checked)} + label="We're a co-operative union or farm" + description="For members with a TIN but no business licence. You'll type your registration details instead of us pulling them from eTrade, and upload your co-operative papers in place of a trade licence." + /> What does your company do?(multiple) @@ -537,6 +569,9 @@ export default function OnboardingWizardDialog({ value={roles} onChange={setRoles} embedded + // Forwarding is licensed work — a co-op holds no licence, so + // the role is not offered rather than refused later. + excludeTypes={cooperative ? ["freight_forwarder"] : undefined} /> {startError && ( diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 93ab3cfc2..f4a788437 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -36,7 +36,9 @@ import type { import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep"; import OwnerStep from "./companyProfileForm/steps/OwnerStep"; import ContactStep from "./companyProfileForm/steps/ContactStep"; -import RepresentationStep from "./companyProfileForm/steps/RepresentationStep"; +import RepresentationStep, { + type IdentityMethod, +} from "./companyProfileForm/steps/RepresentationStep"; import DocumentsStep from "./companyProfileForm/steps/DocumentsStep"; export default function CompanyProfileForm({ @@ -60,6 +62,9 @@ export default function CompanyProfileForm({ onUploadDocuments, identity: rawIdentity, onIdentityChange, + cooperative = false, + declarationLocked = false, + extraDocumentSettingCode, }: { documentSettingCode: string; documentFiles?: Record; @@ -105,6 +110,20 @@ export default function CompanyProfileForm({ * a freshly booted app, so it has nothing to notify. */ onIdentityChange?: () => void; + /** + * The company trades as a co-operative: a TIN but no business licence, so the + * eTrade lookup is replaced by typed registration details, the per-role + * licence upload is not owed, and its own document set applies on top of the + * nationality one. + */ + cooperative?: boolean; + /** + * The company operates as a freight forwarder, so the power-of-attorney + * answer is forced to "yes" and cannot be changed here. + */ + declarationLocked?: boolean; + /** Additional document set merged in (the co-operative one), if any. */ + extraDocumentSettingCode?: string | null; }) { // A Fayda claim carries the phone as the national registry holds it, which is // often a local number the form's E.164 validation (and the API's @@ -168,12 +187,36 @@ export default function CompanyProfileForm({ const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; - const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( + const { data: nationalitySetting, isLoading: loadingDocuments } = useQuery( api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false, }), ); + // A co-operative's own documents come as a second, additive set — it uploads + // everything its nationality demands, plus the papers standing in for the + // business licence it does not hold. The API merges the same two sets when it + // decides what is outstanding. + const { data: extraSetting } = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: extraDocumentSettingCode ?? "" }, + enabled: Boolean(extraDocumentSettingCode), + refetchOnMount: false, + }), + ); + const uploadSetting = useMemo(() => { + if (!nationalitySetting) return nationalitySetting; + if (!extraSetting?.fields?.length) return nationalitySetting; + // Nationality wins a fileKey collision, so a slot is never rendered twice. + const seen = new Set(nationalitySetting.fields.map((f) => f.fileKey)); + return { + ...nationalitySetting, + fields: [ + ...nationalitySetting.fields, + ...extraSetting.fields.filter((f) => !seen.has(f.fileKey)), + ], + }; + }, [nationalitySetting, extraSetting]); // Which fields the current step renders an input for and therefore requires. // Filled in further down (it depends on values this form owns), and read at @@ -388,30 +431,58 @@ export default function CompanyProfileForm({ }; /** - * What no source supplied, per person. + * Which fields a verification owns, per person. * * A Fayda verification owns the fields its claims filled — the API refuses to * let those be overwritten — but its email, phone and address claims are - * optional and routinely come back empty. eTrade fills the owner's name and - * phone, and nothing at all fills an email. + * optional and routinely come back empty. Everything it did NOT fill stays + * the customer's: an editable input, prefilled from eTrade or from what was + * saved earlier, and required precisely because there is an input for it. * - * So "what still has to be asked" varies per company. Computed here, once, - * and handed to both the step (which renders an input per gap) and the schema - * (which requires exactly those): **a field is required if and only if there - * is an input on screen to fix it in.** + * Keyed off `verified`, deliberately, not off "does a value exist". A value + * exists the moment eTrade prefills the owner or the customer types one and + * the step saves — so a presence test turned the input they had just filled + * into a read-only badge on the way back through the wizard, and dropped the + * field out of `requiredKeys` at the same time. Only a verification locks. */ - const ownerGaps = { - name: !identity?.owner.name?.trim(), - email: !identity?.owner.email?.trim(), - phone: !identity?.owner.phone?.trim(), + const ownerVerified = identity?.owner.verified ?? false; + const poaVerified = identity?.poa.verified ?? false; + const ownerLocked = { + name: ownerVerified && Boolean(identity?.owner.name?.trim()), + email: ownerVerified && Boolean(identity?.owner.email?.trim()), + phone: ownerVerified && Boolean(identity?.owner.phone?.trim()), }; - const poaGaps = { - name: !identity?.poa.name?.trim(), - email: !identity?.poa.email?.trim(), - phone: !identity?.poa.phone?.trim(), - address: !identity?.poa.address?.trim(), + const poaLocked = { + name: poaVerified && Boolean(identity?.poa.name?.trim()), + email: poaVerified && Boolean(identity?.poa.email?.trim()), + phone: poaVerified && Boolean(identity?.poa.phone?.trim()), + address: poaVerified && Boolean(identity?.poa.address?.trim()), }; + /** + * How a foreign company chose to prove its subject: Fayda, or a passport. + * + * An either/or rather than a fallback, so nothing is asked until one side is + * picked. Seeded from what already happened — a completed verification or a + * saved passport number is itself the answer — and only then held locally, + * because the choice is a UI fork with nothing to persist: what the API + * stores is the proof, not the route taken to it. + */ + const [identityMethod, setIdentityMethod] = useState( + null, + ); + const passportSaved = Boolean( + identity?.subject === "poa" + ? identity?.poa.passportNumber?.trim() + : identity?.owner.passportNumber?.trim(), + ); + const subjectVerified = identity?.subject === "poa" ? poaVerified : ownerVerified; + const effectiveMethod: IdentityMethod | null = !identity?.passportAccepted + ? "fayda" // An Ethiopian company has no choice to make. + : subjectVerified + ? "fayda" + : (identityMethod ?? (passportSaved ? "passport" : null)); + // The owner's name from whichever source established them — powers the // contact step's "same as owner" card. const ownerName = firstPresent(identity?.owner.name, watch("ownerName")); @@ -494,9 +565,12 @@ export default function CompanyProfileForm({ return errs; }; - // Every role needs at least one license file (existing or newly selected). + // Every role needs at least one license file (existing or newly selected) — + // except a co-operative's, which holds no business licence at all. Its own + // document set is what stands in, and the API lifts the same requirement. const validateLicenses = (): Record => { const errs: Record = {}; + if (cooperative) return errs; for (const p of roleProfiles ?? []) { const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0; const hasExisting = p.existingFiles.length > 0; @@ -549,7 +623,10 @@ export default function CompanyProfileForm({ const hasRegistrationDetails = registration.some((v) => v && v.trim()); // A previously-saved (rehydrated) TIN counts as verified without a refetch — // the registration fields being populated at all is proof it passed before. - const tinVerified = tinStatus === "verified" || hasRegistrationDetails; + // A co-operative never runs the lookup, so there is nothing to be verified + // against; its TIN is validated by the schema like any other typed field. + const tinVerified = + cooperative || tinStatus === "verified" || hasRegistrationDetails; // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. @@ -589,18 +666,29 @@ export default function CompanyProfileForm({ Boolean(watch(passportField)?.trim())); const requiredKeys: (keyof FormData)[] = []; - if (step === "owner") { + if (step === "company" && cooperative) { + // A co-operative has no eTrade record, so the fields every other company + // gets read-only from the licence are typed here — and are therefore + // required here. House number stays optional: plenty of addresses have none. + requiredKeys.push("companyName", "region", "zone", "woreda", "kebele"); + } else if (step === "owner") { // All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input - // is rendered for each one a Fayda claim did not already own. - if (ownerGaps.name) requiredKeys.push("ownerName"); - if (ownerGaps.email) requiredKeys.push("ownerEmail"); - if (ownerGaps.phone) requiredKeys.push("ownerPhone"); - } else if (step === "representation" && identity?.poaDeclared === "yes") { - // Only once a representative is actually declared: a company that answered - // "no" has no representative to describe. - if (poaGaps.name) requiredKeys.push("poaName"); - if (poaGaps.email) requiredKeys.push("poaEmail"); - if (poaGaps.phone) requiredKeys.push("poaPhone"); + // is rendered for each one a Fayda verification does not own. + if (!ownerLocked.name) requiredKeys.push("ownerName"); + if (!ownerLocked.email) requiredKeys.push("ownerEmail"); + if (!ownerLocked.phone) requiredKeys.push("ownerPhone"); + } else if ( + step === "representation" && + identity?.poaDeclared === "yes" && + // The details are only on screen once the person is established — before + // that the step is still asking how to prove them, and requiring a name + // with no input rendered is the dead Continue button this rule exists to + // prevent. + (poaVerified || effectiveMethod === "passport") + ) { + if (!poaLocked.name) requiredKeys.push("poaName"); + if (!poaLocked.email) requiredKeys.push("poaEmail"); + if (!poaLocked.phone) requiredKeys.push("poaPhone"); } requiredKeysRef.current = requiredKeys; @@ -695,6 +783,8 @@ export default function CompanyProfileForm({ } // The TIN must resolve to a real eTrade record before anything else on // this step is even worth validating — gates here rather than through zod. + // A co-operative is exempt: it has no licence for eTrade to hold, so + // `tinVerified` is true for it and only the duplicate-TIN check applies. if (step === "company" && tinStatus === "taken") { setSaveError( "This TIN is already registered to another company account.", @@ -793,6 +883,7 @@ export default function CompanyProfileForm({ tinStatus={tinStatus} tinVerified={tinVerified} hasRegistrationDetails={hasRegistrationDetails} + cooperative={cooperative} onETradeDataLoaded={handleETradeDataLoaded} onETradeStatusChange={setTinStatus} onETradeReset={handleETradeReset} @@ -804,7 +895,8 @@ export default function CompanyProfileForm({ form={form} identity={identity} etradeOwner={etradeOwner} - gaps={ownerGaps} + locked={ownerLocked} + cooperative={cooperative} /> )} @@ -814,7 +906,10 @@ export default function CompanyProfileForm({ identity={identity} onDeclare={handleDeclare} declarePending={declarePending} - gaps={poaGaps} + declarationLocked={declarationLocked} + locked={poaLocked} + method={effectiveMethod} + onMethodChange={setIdentityMethod} poaDocumentSetting={poaDocumentSetting} documentFiles={documentFiles} uploadedDocumentKeys={uploadedDocumentKeys} @@ -840,7 +935,10 @@ export default function CompanyProfileForm({ uploadedDocumentKeys={uploadedDocumentKeys} documentFieldErrors={documentFieldErrors} onDocumentFilesChange={handleDocumentFilesChange} - roleProfiles={roleProfiles} + // A co-operative union or farm holds no business licence, so the + // per-role upload cards are not shown at all — offering a slot + // nothing can fill reads as an unfinishable step. + roleProfiles={cooperative ? [] : roleProfiles} licenseFiles={licenseFiles} licenseFieldErrors={licenseFieldErrors} onLicenseFilesChange={handleLicenseFilesChange} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts index ebff24687..4b5b7cdb7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts @@ -65,18 +65,16 @@ describe("VAT number", () => { ).toBeUndefined(); }); - it("rejects twelve digits", () => { - expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe( - "VAT number must be 10 or 11 digits", - ); - }); - - // `.length(10)` used to pass this, so a ten-letter string reached the API. - it("rejects ten non-digits", () => { - expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe( - "VAT number must be 10 or 11 digits", - ); - }); + // No shape rule any more: a foreign tax authority's VAT number carries + // letters and dashes, and a co-operative union's registration numbering + // follows the trade-licence pattern not at all. Length and alphabet are not + // ours to police — only presence is. + it.each(["001234567890", "GB123456789", "ET-2024/0091"])( + "accepts %s", + (vat) => { + expect(errorFor(values({ vatNumber: vat }), "vatNumber")).toBeUndefined(); + }, + ); it("rejects blank", () => { expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe( @@ -104,24 +102,44 @@ describe("stepFields", () => { // The regression this whole change exists to prevent: a step must not gate on // a field it renders no input for, or Continue fails with the error attached // to nothing on screen. - it("never gates the company step on a derived or read-only field", () => { - const unreachable = [ - "etradePhone", + // + // Listing a field on a step is no longer the gate — `requiredKeys` is. The + // registration fields appear on the company step because a co-operative union + // or farm types them, and a licensed company gets them read-only from eTrade; + // the base schema must accept them blank either way. + it("never gates the company step on a field with no input", () => { + const derived = ["etradePhone", "licenceNumber", "statusDescription"]; + expect(stepFields.company.filter((f) => derived.includes(f))).toEqual([]); + }); + + it("leaves the registration fields optional in the base schema", () => { + for (const field of ["companyName", "region", "zone", "woreda", "kebele"] as const) { + expect(errorFor(values({ [field]: "" }), field)).toBeUndefined(); + } + }); + + it("requires the registration fields once a co-operative types them", () => { + const parsed = buildOnboardingSchema([ "companyName", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", "region", "zone", "woreda", "kebele", - "houseNo", - ]; - expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual( - [], + ]).safeParse( + values({ companyName: "", region: "", zone: "", woreda: "", kebele: "" }), + ); + expect(parsed.success).toBe(false); + const paths = parsed.success + ? [] + : parsed.error.issues.map((i) => String(i.path[0])); + expect(paths).toEqual( + expect.arrayContaining([ + "companyName", + "region", + "zone", + "woreda", + "kebele", + ]), ); }); }); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 770e565b8..d642e48c7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -22,11 +22,11 @@ export const onboardingSchema = z.object({ // can diverge without the backend's eTrade-authenticity check misfiring. etradePhone: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), - // `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits. - vatNumber: z - .string() - .min(1, "VAT number is required") - .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"), + // Required, but no shape check. Ethiopian VAT numbers are usually 10 or 11 + // digits; a foreign company's is whatever its own tax authority issues, and a + // co-operative's registration numbering follows neither. A format rule here + // only ever rejected valid numbers we had no business judging. + vatNumber: z.string().min(1, "VAT number is required"), // Passport numbers — the alternative identity credential for a foreign // company (Fayda is an Ethiopian national ID). Only the one belonging to the // declared identity subject is ever asked for, and only when that person has @@ -40,10 +40,15 @@ export const onboardingSchema = z.object({ renewedFrom: z.string().optional(), renewalDate: z.string().optional(), renewedTo: z.string().optional(), - // The registered address comes from eTrade and nowhere else — the form - // renders these read-only, so requiring them would be a Continue button that - // fails on a field with no input to fix it. A gap in eTrade's own data stays - // a gap rather than becoming a customer-typed claim wearing eTrade's badge. + // The registered address normally comes from eTrade and nowhere else — the + // form renders these read-only, so requiring them would be a Continue button + // that fails on a field with no input to fix it. A gap in eTrade's own data + // stays a gap rather than becoming a customer-typed claim wearing eTrade's + // badge. + // + // A co-operative is the exception: it has no business licence, so there is no + // eTrade record at all and these ARE typed. Requiredness follows the same + // invariant as everywhere else — it is decided per render, in `requiredKeys`. region: z.string().optional(), zone: z.string().optional(), woreda: z.string().optional(), @@ -107,6 +112,12 @@ export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; * message has to be built here rather than attached to the base schema. */ const CONDITIONAL_LABELS: Partial> = { + // Typed only by a co-operative — every other company gets these from eTrade. + companyName: "Company name", + region: "Region", + zone: "Zone", + woreda: "Woreda", + kebele: "Kebele", poaName: "Representative's name", poaEmail: "Representative's email", poaPhone: "Representative's phone", @@ -184,8 +195,18 @@ export const ETRADE_BUNDLE_FIELDS = [ */ export const stepFields: Record = { // Only what this step actually renders an input for. The company name and the - // registered address are eTrade's, shown read-only. - company: ["tinNumber", "vatNumber"], + // registered address are eTrade's, shown read-only — except for a + // co-operative, which types them (added per render via `requiredKeys`). + company: [ + "tinNumber", + "vatNumber", + "companyName", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + ], // `REQUIRED_COMPANY_INFO` demands all three server-side, so the step offers // an input wherever eTrade and Fayda between them left a gap. owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerPassportNumber"], diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx index f57d7c433..5e4ad7f85 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -1,7 +1,7 @@ -import { Stack, TextInput } from "@mantine/core"; +import { Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core"; import type { UseFormReturn } from "react-hook-form"; -import type { CompanyRegistrationData } from "@edr/types"; +import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types"; import ETradeInfo, { type ETradeStatus, } from "@/components/onboarding/ETradeInfo"; @@ -16,6 +16,12 @@ export interface CompanyInfoStepProps { tinVerified: boolean; /** Registration fields are already populated (a lookup passed, now or earlier). */ hasRegistrationDetails: boolean; + /** + * The company is a co-operative union or farm: it has a TIN but no business + * licence, so eTrade holds no record to look up and the registration is typed + * here instead. + */ + cooperative?: boolean; onETradeDataLoaded: (data: CompanyRegistrationData) => void; onETradeStatusChange: (status: ETradeStatus) => void; onETradeReset: () => void; @@ -26,6 +32,7 @@ export default function CompanyInfoStep({ tinStatus, tinVerified, hasRegistrationDetails, + cooperative = false, onETradeDataLoaded, onETradeStatusChange, onETradeReset, @@ -33,6 +40,7 @@ export default function CompanyInfoStep({ const { register, watch, + setValue, formState: { errors }, } = form; @@ -41,43 +49,112 @@ export default function CompanyInfoStep({ = 10 && !errors.vatNumber - ? "done" - : "todo" - } + status={watch("vatNumber")?.trim() && !errors.vatNumber ? "done" : "todo"} > - - - {tinVerified && ( - - )} - + {cooperative ? ( + <> + + + + + + + + + Registered address + + +