mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -18,6 +18,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
@@ -25,6 +26,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
@@ -145,6 +147,18 @@ export interface GenerateInvoiceInput {
|
||||
status?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */
|
||||
export type MemoType = "CRE" | "DEB";
|
||||
|
||||
/** Everything needed to issue a credit or debit memo against an already-registered invoice. */
|
||||
export interface IssueMemoInput {
|
||||
type: MemoType;
|
||||
/** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */
|
||||
reason: string;
|
||||
/** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */
|
||||
lines?: InvoiceLineInput[];
|
||||
}
|
||||
|
||||
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||
export interface InvoiceEventPayload {
|
||||
invoiceId: string;
|
||||
@@ -481,12 +495,20 @@ export class BillingService {
|
||||
|
||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||
|
||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
/**
|
||||
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
|
||||
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
|
||||
* boundary check, not a business rule.
|
||||
*/
|
||||
async document(
|
||||
id: string,
|
||||
format: "a4" | "thermal" = "a4",
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(
|
||||
await this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
const model = await this.toDocumentModel(invoice, "INVOICE");
|
||||
return format === "thermal"
|
||||
? this.invoiceDocuments.renderThermal(model)
|
||||
: this.invoiceDocuments.render(model);
|
||||
}
|
||||
|
||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||
@@ -502,15 +524,6 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
|
||||
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
|
||||
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
|
||||
*/
|
||||
private renderEimsQr(signedQr: string): string {
|
||||
return `data:image/png;base64,${signedQr}`;
|
||||
}
|
||||
|
||||
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
|
||||
private async bookingSummaryRows(
|
||||
invoice: Invoice,
|
||||
@@ -626,7 +639,7 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -793,11 +806,16 @@ export class BillingService {
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
/**
|
||||
* `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
|
||||
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
|
||||
* daily sequence (different prefix hashes to a different advisory lock, see
|
||||
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
|
||||
*/
|
||||
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
|
||||
return nextDailyInvoiceNumber(mg, {
|
||||
table: "freight.invoices",
|
||||
code: "INV",
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -820,9 +838,123 @@ export class BillingService {
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
|
||||
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
|
||||
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
|
||||
* unchanged: it has no side effects (no events, no notifications, no payment records — every
|
||||
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
|
||||
* is just an ordinary invoice with three extra columns set.
|
||||
*
|
||||
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
|
||||
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
|
||||
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
|
||||
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
|
||||
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
|
||||
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
|
||||
*
|
||||
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
|
||||
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
|
||||
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
|
||||
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
|
||||
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
|
||||
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
|
||||
*/
|
||||
async issueMemo(
|
||||
originalId: string,
|
||||
input: IssueMemoInput,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const reason = input.reason?.trim();
|
||||
if (!reason) {
|
||||
throw new BadRequestException("A memo requires a reason.");
|
||||
}
|
||||
|
||||
const original = await this.findById(originalId);
|
||||
if (!original.eimsIrn) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
||||
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
|
||||
});
|
||||
}
|
||||
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
|
||||
throw new BadRequestException(
|
||||
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
|
||||
);
|
||||
}
|
||||
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException(
|
||||
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceLines = input.lines?.length ? input.lines : original.lines;
|
||||
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
|
||||
chargeType: l.chargeType,
|
||||
description: l.description,
|
||||
quantity: Number(l.quantity),
|
||||
unitRate: Number(l.unitRate),
|
||||
amount: Number(l.amount),
|
||||
currency: l.currency,
|
||||
metadata: l.metadata ?? null,
|
||||
}));
|
||||
|
||||
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
|
||||
if (!(total > 0)) {
|
||||
throw new BadRequestException("A memo must have a positive total.");
|
||||
}
|
||||
// Only a credit note is bounded by the original — it can only give back what was charged. A
|
||||
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
|
||||
// assume the credit-note ceiling is correct for DEB).
|
||||
if (input.type === "CRE" && total > Number(original.totalAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = input.type === "CRE" ? "CRE" : "DEB";
|
||||
const settled = input.type === "CRE";
|
||||
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const memo = await this.createInvoice(
|
||||
{
|
||||
source: original.source as Freight.InvoiceSource,
|
||||
sourceId: original.id,
|
||||
type: input.type === "CRE" ? "credit_note" : "debit_note",
|
||||
companyId: original.companyId,
|
||||
companyProfileId: original.companyProfileId,
|
||||
shippingLineCompanyId: original.shippingLineCompanyId,
|
||||
lines,
|
||||
currency: original.currency,
|
||||
subtotalAmount: total,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
|
||||
},
|
||||
mg,
|
||||
code,
|
||||
);
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
eimsDocumentType: input.type,
|
||||
eimsReason: reason,
|
||||
relatedInvoiceId: original.id,
|
||||
...(settled
|
||||
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, memo.id, patch);
|
||||
|
||||
this.logger.log(
|
||||
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
|
||||
);
|
||||
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
|
||||
});
|
||||
}
|
||||
|
||||
private async createInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
mg: EntityManager,
|
||||
code = "INV",
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const currency = input.currency ?? "ETB";
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
@@ -870,7 +1002,7 @@ export class BillingService {
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
||||
|
||||
const invoice = await mg.save(
|
||||
mg.create(Invoice, {
|
||||
|
||||
Reference in New Issue
Block a user