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

@@ -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],

View File

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