mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
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:
@@ -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 }]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user