feat(billing): show the buyer's trade name on invoice documents

An invoice is billed to one company profile, and that profile's eTrade
licence is usually a different business from the one the company
registered under — so the buyer's name alone does not say which business
was billed.

Both document paths gain a row: the shared invoice/receipt model reads it
off the already-loaded `companyProfile` relation, and the warehouse fee
invoice joins `company_profiles` through the booking.

`sameCompanyName` suppresses the row when it merely repeats the buyer
name, which is the common case. It compares loosely because eTrade spells
one legal suffix three ways (PLC / P L C / PRIVATE LIMITED COMPANY) and
pads names with double spaces; it decides whether a row is worth printing
and nothing else.

The EIMS buyer `LegalName` is deliberately untouched — a MoR filing
carries the registered entity, same rule as the seller side.
This commit is contained in:
Nathnael
2026-08-27 09:29:25 +00:00
parent 604710bd25
commit ab5e7e6db6
4 changed files with 96 additions and 1 deletions

View File

@@ -29,6 +29,7 @@ import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
sameCompanyName,
InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service";
@@ -875,10 +876,21 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
const summary: InvoiceDocumentModel["summary"] = [
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
// filed against, not just the seller. VatNumber shown only when the company has one.
{ label: "Buyer", value: invoice.company?.name ?? null },
// The trade name of the eTrade licence THIS profile operates as. A TIN
// holds many licences and the invoiced role (importer/exporter/forwarder)
// is usually a different business from the one the company registered
// under, so the buyer's name alone doesn't say which one was billed.
// Suppressed when it just repeats the buyer name — most companies trade
// under their registered name and a duplicate row helps nobody.
...(tradeName && !sameCompanyName(tradeName, invoice.company?.name)
? [{ label: "Buyer trade name", value: tradeName }]
: []),
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
...(invoice.company?.vatNumber
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]

View File

@@ -1,4 +1,4 @@
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service";
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
kind: "INVOICE",
@@ -87,3 +87,38 @@ describe("InvoiceDocumentService.buildThermalHtml", () => {
expect(html).not.toContain("right: 160px");
});
});
describe("sameCompanyName", () => {
it("treats eTrade's legal-suffix spellings as the same name", () => {
expect(sameCompanyName("ABIJOEL PLC", "ABIJOEL P L C")).toBe(true);
expect(
sameCompanyName(
"WISH TRADING PLC",
"WISH TRADING PRIVATE LIMITED COMPANY",
),
).toBe(true);
expect(
sameCompanyName("TUTA TRADING PLC", "TUTA TRADING ONE MEMBER PLC"),
).toBe(true);
});
it("keeps a genuinely different trade name distinct", () => {
// Real pairs from eTrade: the licence trades under a different name than
// the company registered under, which is exactly the row worth printing.
expect(
sameCompanyName("Cozy Coffee Grower and Exporter", "ABIJOEL P L C"),
).toBe(false);
expect(sameCompanyName("MENNA PRODUCTION", "ICOFFEE TRADING PLC")).toBe(
false,
);
expect(
sameCompanyName("YUNABEK TRADING PLC", "YUNABEK INVESTMENT PLC"),
).toBe(false);
});
it("is false when either side is missing, so no row is printed", () => {
expect(sameCompanyName("", "ABIJOEL P L C")).toBe(false);
expect(sameCompanyName(null, null)).toBe(false);
expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false);
});
});

View File

@@ -48,6 +48,34 @@ function formatDate(value: unknown): string {
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
}
/**
* Is this trade name just the company name again?
*
* Compared loosely on purpose: eTrade spells the same legal suffix as "PLC",
* "P L C" and "PRIVATE LIMITED COMPANY", and pads names with double spaces, so
* an exact comparison would call two spellings of one name different and print
* a redundant row. Used only to decide whether a trade-name row is worth
* showing — never to decide that two businesses ARE the same.
*/
export function sameCompanyName(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const norm = (v: string | null | undefined) =>
(v ?? "")
.toUpperCase()
.replace(/[.,]/g, "")
.replace(/\s+/g, " ")
.trim()
.replace(/\bPRIVATE LIMITED COMPANY\b/g, "PLC")
.replace(/\bP L C\b/g, "PLC")
.replace(/\bONE (MEMBER|PERSON) PLC\b/g, "PLC")
.replace(/\s+/g, " ")
.trim();
const left = norm(a);
return left !== "" && left === norm(b);
}
/** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine {
description: string | null;

View File

@@ -22,6 +22,7 @@ import { InvoiceLine } from "../billing/entities/invoice-line.entity";
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
import {
InvoiceDocumentModel,
sameCompanyName,
InvoiceDocumentService,
} from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service";
@@ -71,6 +72,11 @@ const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
export interface InvoiceDocumentDetails {
bookingReference: string | null;
customerName: string | null;
/**
* Trade name of the eTrade licence the billed company profile operates as.
* Null when nothing is attached, or for a company with no eTrade record.
*/
customerTradeName: string | null;
inventoryReference: string | null;
inventoryInfo: string | null;
inventoryStatus: string | null;
@@ -745,6 +751,17 @@ export class WarehouseInvoiceService {
},
{ label: "Booking reference", value: invoice.bookingReference ?? null },
{ label: "Customer", value: invoice.customerName ?? null },
// Which of the TIN's eTrade businesses was billed. Omitted when it just
// repeats the customer name — see sameCompanyName.
...(invoice.customerTradeName &&
!sameCompanyName(invoice.customerTradeName, invoice.customerName)
? [
{
label: "Customer trade name",
value: invoice.customerTradeName,
},
]
: []),
{
label: "Inventory reference",
value: invoice.inventoryReference ?? null,
@@ -795,6 +812,7 @@ export class WarehouseInvoiceService {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
cp.etrade_business->>'tradeName' AS "customerTradeName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
inv.release_date AS "releaseDate",
@@ -812,6 +830,7 @@ export class WarehouseInvoiceService {
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.company_profiles cp ON cp.id = b.company_profile_id AND cp.deleted_at IS NULL
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -837,6 +856,7 @@ export class WarehouseInvoiceService {
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
customerTradeName: row?.customerTradeName ?? null,
inventoryReference: row?.inventoryReference ?? null,
inventoryInfo: row?.inventoryInfo ?? null,
inventoryStatus: row?.inventoryStatus ?? null,