mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1326 from Tria-plc/eims-integration
Eims integration
This commit is contained in:
@@ -151,7 +151,9 @@ describe("toEimsInvoice", () => {
|
||||
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
|
||||
Discount: 25,
|
||||
TotalLineAmount: 1050,
|
||||
Unit: "CTR",
|
||||
// Not "CTR" from the line's metadata.unit — that's our internal fee-basis tag, not a MoR
|
||||
// unit of measure, and is never read for this field (see the mapper's own comment).
|
||||
Unit: "PCS",
|
||||
});
|
||||
expect(doc.ValueDetails).toEqual({
|
||||
Discount: null,
|
||||
|
||||
@@ -463,7 +463,13 @@ export function toEimsInvoice(
|
||||
const PreTaxValue = round2(num(line.amount));
|
||||
const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100);
|
||||
const ExciseTaxValue = round2(tax.exciseTaxValue);
|
||||
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
|
||||
// `line.metadata.unit` is our own fee-basis tag (PER_CONTAINER/PER_TON/PER_ITEM — how a charge
|
||||
// is computed, see the fee-rule docs), never a MoR unit of measure — sending it as-is here
|
||||
// (confirmed live 2026-08-17: "PER_CONTAINER" fails Unit's enum, its 8-char max, and its regex
|
||||
// all at once) is what a prior version of this mapper did by mistake. MoR's own enum
|
||||
// (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG) has no freight-shipment concept at all, so every line
|
||||
// uses the single configured default rather than guessing a per-line value that doesn't exist.
|
||||
const unit = context.unitDefault;
|
||||
|
||||
return {
|
||||
Discount: round2(tax.discount),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { ArrayMinSize, IsArray, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator";
|
||||
|
||||
export class BulkCancelEimsItemDto {
|
||||
@ApiProperty({ description: "Invoice ID to cancel." })
|
||||
@IsUUID()
|
||||
invoiceId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).',
|
||||
example: "1",
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 8)
|
||||
reasonCode!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 500)
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/** `POST invoices/eims/bulk-cancel` body — see `EimsCancellationService.cancelBulkWithEims`. */
|
||||
export class BulkCancelEimsRegistrationDto {
|
||||
@ApiProperty({ type: [BulkCancelEimsItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BulkCancelEimsItemDto)
|
||||
items!: BulkCancelEimsItemDto[];
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import { EimsApiException } from "./eims.errors";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
|
||||
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||
const OTHER_IRN = "0af579eaef6f1e2d39fa77bd21cf8ecc64e26869275ae1c04eaa9ffea78b6c06";
|
||||
|
||||
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||
({
|
||||
@@ -153,3 +155,105 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => {
|
||||
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsCancellationService.cancelBulkWithEims", () => {
|
||||
it("cancels every eligible invoice in one call, matching results back by IRN", async () => {
|
||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
|
||||
const postBearer = jest.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: [
|
||||
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
|
||||
{ id: 2, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
|
||||
],
|
||||
});
|
||||
|
||||
const results = await build(db, postBearer).cancelBulkWithEims([
|
||||
{ invoiceId: INVOICE_ID, reasonCode: "1" },
|
||||
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "6", remark: "x" },
|
||||
]);
|
||||
|
||||
expect(postBearer).toHaveBeenCalledWith("/v1/bulkCancel", [
|
||||
{ Irn: IRN, ReasonCode: "1", Remark: "" },
|
||||
{ Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
|
||||
]);
|
||||
expect(results).toEqual([
|
||||
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
|
||||
{ invoiceId: OTHER_INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
|
||||
]);
|
||||
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||
// Bulk success carries no cancellationDate at all, unlike single cancel.
|
||||
expect(db.invoices.get(INVOICE_ID)?.eimsCancellationDate).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an already-cancelled or never-registered invoice locally — never sent to MoR", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled }),
|
||||
invoiceRow({ id: OTHER_INVOICE_ID, eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null }),
|
||||
]);
|
||||
const postBearer = jest.fn();
|
||||
|
||||
const results = await build(db, postBearer).cancelBulkWithEims([
|
||||
{ invoiceId: INVOICE_ID, reasonCode: "1" },
|
||||
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
|
||||
]);
|
||||
|
||||
expect(postBearer).not.toHaveBeenCalled();
|
||||
expect(results).toEqual([
|
||||
{ invoiceId: INVOICE_ID, success: false, message: expect.stringContaining("already cancelled") },
|
||||
{ invoiceId: OTHER_INVOICE_ID, success: false, message: expect.stringContaining("never registered") },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a mix of MoR success and rejection only updates the succeeding invoice", async () => {
|
||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
|
||||
const postBearer = jest.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: [
|
||||
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
|
||||
{ Status: "Processing_Error", msg: "IRN already Canceled.", Irn: OTHER_IRN },
|
||||
],
|
||||
});
|
||||
|
||||
const results = await build(db, postBearer).cancelBulkWithEims([
|
||||
{ invoiceId: INVOICE_ID, reasonCode: "1" },
|
||||
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
|
||||
]);
|
||||
|
||||
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||
expect(results).toEqual([
|
||||
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
|
||||
{ invoiceId: OTHER_INVOICE_ID, success: false, message: "IRN already Canceled." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("makes no HTTP call at all when every item fails the local eligibility check", async () => {
|
||||
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
|
||||
const postBearer = jest.fn();
|
||||
|
||||
await build(db, postBearer).cancelBulkWithEims([{ invoiceId: INVOICE_ID, reasonCode: "1" }]);
|
||||
|
||||
expect(postBearer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies the buyer only for invoices that actually got cancelled", async () => {
|
||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
|
||||
db.companyContact = { phone: "+251911000000", email: null };
|
||||
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||
const postBearer = jest.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
body: [
|
||||
{ status: "C", Irn: IRN },
|
||||
{ Status: "Processing_Error", msg: "boom", Irn: OTHER_IRN },
|
||||
],
|
||||
});
|
||||
|
||||
await build(db, postBearer, directSend).cancelBulkWithEims([
|
||||
{ invoiceId: INVOICE_ID, reasonCode: "1" },
|
||||
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
|
||||
]);
|
||||
|
||||
expect(directSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,15 @@ import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
|
||||
import {
|
||||
EimsBulkCancelItemResult,
|
||||
EimsBulkCancelRequest,
|
||||
EimsBulkCancelResponse,
|
||||
EimsCancelRequest,
|
||||
EimsCancelResponse,
|
||||
EimsInvoiceStatus,
|
||||
EimsInvoiceStatusView,
|
||||
} from "./eims-registration.types";
|
||||
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
||||
|
||||
/**
|
||||
@@ -92,6 +100,102 @@ export class EimsCancellationService {
|
||||
return this.getEimsCancellationStatus(invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /v1/bulkCancel` — one MoR call for every eligible invoice in `items`, matching the
|
||||
* collection's own shape (an array in, an array of mixed success/error results back).
|
||||
*
|
||||
* Same local-eligibility doctrine as `cancelInvoiceWithEims`, applied per item before anything
|
||||
* goes to MoR: an already-cancelled or never-registered invoice is refused right here (no HTTP
|
||||
* call, no seat in the batch) rather than sent and rejected remotely. Only genuinely eligible
|
||||
* invoices are batched into the one `/v1/bulkCancel` request; everything else is reported back
|
||||
* immediately.
|
||||
*
|
||||
* ponytail: the eligibility pass is per-invoice transactions, not one covering the whole batch —
|
||||
* same reasoning as the single-cancel path (cancel is idempotent at MoR, so a lock held across
|
||||
* every item for the whole call isn't needed for correctness, only for avoiding a wasted call on
|
||||
* an item that's already ineligible).
|
||||
*/
|
||||
async cancelBulkWithEims(
|
||||
items: Array<{ invoiceId: string; reasonCode: string; remark?: string }>,
|
||||
): Promise<EimsBulkCancelItemResult[]> {
|
||||
const results = new Map<string, EimsBulkCancelItemResult>();
|
||||
const eligible: Array<{ invoice: Invoice; reasonCode: string; remark?: string }> = [];
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const invoice = await this.dataSource.transaction(async (manager) => {
|
||||
const inv = await this.lockInvoice(manager, item.invoiceId);
|
||||
if (inv.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
||||
throw new ConflictException(
|
||||
`Invoice ${inv.invoiceNumber} was already cancelled with EIMS${inv.eimsCancellationDate ? ` (${inv.eimsCancellationDate})` : ""}.`,
|
||||
);
|
||||
}
|
||||
if (!inv.eimsIrn) {
|
||||
throw new BadRequestException(
|
||||
`Invoice ${inv.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
|
||||
);
|
||||
}
|
||||
return inv;
|
||||
});
|
||||
eligible.push({ invoice, reasonCode: item.reasonCode, remark: item.remark });
|
||||
} catch (err) {
|
||||
results.set(item.invoiceId, {
|
||||
invoiceId: item.invoiceId,
|
||||
success: false,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (eligible.length > 0) {
|
||||
const request: EimsBulkCancelRequest = eligible.map((e) => ({
|
||||
Irn: e.invoice.eimsIrn!,
|
||||
ReasonCode: e.reasonCode,
|
||||
Remark: e.remark ?? "",
|
||||
}));
|
||||
// Outside any transaction — no DB lock held across the wire, same as single cancel.
|
||||
const response = await this.client.postBearer<EimsBulkCancelRequest, EimsBulkCancelResponse>(
|
||||
"/v1/bulkCancel",
|
||||
request,
|
||||
);
|
||||
const byIrn = new Map((response?.body ?? []).map((entry) => [entry.Irn, entry]));
|
||||
|
||||
for (const { invoice, reasonCode, remark } of eligible) {
|
||||
const entry = byIrn.get(invoice.eimsIrn!);
|
||||
const failed = !entry || "Status" in entry;
|
||||
if (failed) {
|
||||
const message = entry && "msg" in entry ? entry.msg : "EIMS bulk cancel returned no result for this invoice.";
|
||||
this.logger.warn(`Invoice ${invoice.invoiceNumber} bulk cancel failed: ${message}`);
|
||||
results.set(invoice.id, { invoiceId: invoice.id, success: false, message });
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const fresh = await this.lockInvoice(manager, invoice.id);
|
||||
// Re-checked under lock: a concurrent call may have already recorded this cancellation.
|
||||
if (fresh.eimsStatus === EimsInvoiceStatus.Cancelled) return;
|
||||
await manager.update(Invoice, invoice.id, {
|
||||
eimsStatus: EimsInvoiceStatus.Cancelled,
|
||||
eimsCancelledAt: new Date(),
|
||||
// The bulk success shape carries no cancellationDate at all, unlike single cancel.
|
||||
eimsCancellationDate: null,
|
||||
eimsCancellationReasonCode: reasonCode,
|
||||
eimsCancellationRemark: remark ?? null,
|
||||
});
|
||||
});
|
||||
this.logger.log(`Invoice ${invoice.invoiceNumber} cancelled with EIMS via bulk (IRN ${invoice.eimsIrn})`);
|
||||
await this.notifyBuyer(invoice);
|
||||
results.set(invoice.id, {
|
||||
invoiceId: invoice.id,
|
||||
success: true,
|
||||
message: `Invoice ${invoice.invoiceNumber} cancelled with EIMS.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items.map((item) => results.get(item.invoiceId)!);
|
||||
}
|
||||
|
||||
async getEimsCancellationStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
||||
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Response } from "express";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { sendPdf } from "../billing/billing.controller";
|
||||
import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto";
|
||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
@@ -89,6 +90,17 @@ export class EimsInvoiceController {
|
||||
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
|
||||
}
|
||||
|
||||
@Post("eims/bulk-cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Cancel multiple invoices' registered EIMS documents in one call. Each invoice's outcome is " +
|
||||
"reported independently — one failure never blocks the rest.",
|
||||
})
|
||||
bulkCancel(@Body() dto: BulkCancelEimsRegistrationDto) {
|
||||
return this.cancellation.cancelBulkWithEims(dto.items);
|
||||
}
|
||||
|
||||
@Post(":id/eims/receipt/sales")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
|
||||
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })
|
||||
|
||||
@@ -89,6 +89,46 @@ export interface EimsCancelResponse {
|
||||
body?: EimsCancelResponseBody;
|
||||
}
|
||||
|
||||
/** `POST /v1/bulkCancel` — an array of the same `Irn`/`ReasonCode`/`Remark` shape as single cancel. */
|
||||
export type EimsBulkCancelRequest = EimsCancelRequest[];
|
||||
|
||||
/**
|
||||
* One element of a `/v1/bulkCancel` response array — MoR mixes success and error shapes in the same
|
||||
* array, one entry per submitted IRN, disambiguated by `Status` (capital, error) vs `status`
|
||||
* (lowercase, success — always `"C"`). Unlike single cancel, a bulk success carries no
|
||||
* `cancellationDate` at all.
|
||||
*/
|
||||
export interface EimsBulkCancelSuccessItem {
|
||||
id?: number;
|
||||
tin?: string;
|
||||
status: string;
|
||||
mode?: string;
|
||||
Irn: string;
|
||||
ReasonCode?: string;
|
||||
Remark?: string;
|
||||
}
|
||||
|
||||
export interface EimsBulkCancelErrorItem {
|
||||
Status: string;
|
||||
msg: string;
|
||||
Irn: string;
|
||||
}
|
||||
|
||||
export type EimsBulkCancelResponseItem = EimsBulkCancelSuccessItem | EimsBulkCancelErrorItem;
|
||||
|
||||
export interface EimsBulkCancelResponse {
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
body?: EimsBulkCancelResponseItem[];
|
||||
}
|
||||
|
||||
/** One invoice's outcome from `cancelBulkWithEims` — local eligibility failure or MoR's own result. */
|
||||
export interface EimsBulkCancelItemResult {
|
||||
invoiceId: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
||||
export interface EimsInvoiceError {
|
||||
kind: string;
|
||||
|
||||
Reference in New Issue
Block a user