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:
Nathnael
2026-08-11 12:54:51 +00:00
parent 72164b0b8e
commit d6e349f329
40 changed files with 1188 additions and 118 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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"] });
}
}

View File

@@ -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)));
});
}
}