mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
eims integration master test complete
This commit is contained in:
@@ -172,14 +172,23 @@ EIMS_SELLER_LOCALITY=
|
|||||||
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
||||||
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
||||||
# locally, naming the missing variables, until these are set.
|
# locally, naming the missing variables, until these are set.
|
||||||
# Required, and deliberately unset: the choice is a tax position, not a default.
|
# Required, and deliberately unset here: the choice is a tax position, not a default.
|
||||||
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
||||||
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
|
# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env.
|
||||||
EIMS_TAX_CODE=
|
EIMS_TAX_CODE=
|
||||||
EIMS_TAX_RATE_PERCENT=0
|
EIMS_TAX_RATE_PERCENT=0
|
||||||
EIMS_EXCISE_TAX_VALUE=0
|
EIMS_EXCISE_TAX_VALUE=0
|
||||||
EIMS_INCOME_WITHHOLD_VALUE=0
|
EIMS_INCOME_WITHHOLD_VALUE=0
|
||||||
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
||||||
|
# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a
|
||||||
|
# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material.
|
||||||
|
# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above.
|
||||||
|
# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types.
|
||||||
|
EIMS_TAX_CODE_BY_CHARGE_TYPE=
|
||||||
|
EIMS_TAX_RATE_BY_CHARGE_TYPE=
|
||||||
|
# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively.
|
||||||
|
EIMS_EXCISE_BY_CHARGE_TYPE=
|
||||||
|
EIMS_DISCOUNT_BY_CHARGE_TYPE=
|
||||||
# Document classification and payment presentation.
|
# Document classification and payment presentation.
|
||||||
EIMS_TRANSACTION_TYPE=B2B
|
EIMS_TRANSACTION_TYPE=B2B
|
||||||
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
|
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
|
||||||
|
|||||||
@@ -89,8 +89,30 @@ export interface EimsInvoiceConfig {
|
|||||||
buyerRegionCodes: Record<string, string>;
|
buyerRegionCodes: Record<string, string>;
|
||||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||||
buyerWeredaCodes: Record<string, string>;
|
buyerWeredaCodes: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||||
|
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||||
|
* `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax
|
||||||
|
* treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above
|
||||||
|
* cannot express that. Values are raw strings; the context builder parses/validates them.
|
||||||
|
*/
|
||||||
|
taxCodeByChargeType: Record<string, string>;
|
||||||
|
taxRateByChargeType: Record<string, string>;
|
||||||
|
/** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge
|
||||||
|
* types not listed fall back to `exciseTaxValue` / 0 respectively. */
|
||||||
|
exciseByChargeType: Record<string, string>;
|
||||||
|
discountByChargeType: Record<string, string>;
|
||||||
cashierName: string | null;
|
cashierName: string | null;
|
||||||
salesPersonName: string | null;
|
salesPersonName: string | null;
|
||||||
|
/**
|
||||||
|
* TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every
|
||||||
|
* buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be
|
||||||
|
* one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead
|
||||||
|
* of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits.
|
||||||
|
* Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists.
|
||||||
|
*/
|
||||||
|
buyerIdType: string | null;
|
||||||
|
buyerIdNumber: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REQUIRED_VARS = [
|
const REQUIRED_VARS = [
|
||||||
@@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => {
|
|||||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||||
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
||||||
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
||||||
|
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||||
|
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||||
|
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||||
|
discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE),
|
||||||
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
||||||
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
||||||
|
buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null,
|
||||||
|
buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed
|
||||||
|
* live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as
|
||||||
|
* `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-`
|
||||||
|
* prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already
|
||||||
|
* accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING`
|
||||||
|
* and the system-wide reservation stuck in-flight with no block/alert (see
|
||||||
|
* `EimsInvoiceRegistrationService` for the accompanying code fix).
|
||||||
|
*
|
||||||
|
* Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or
|
||||||
|
* length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's
|
||||||
|
* real production shape — guessing another fixed bound risks the exact same failure again.
|
||||||
|
*/
|
||||||
|
export class WidenEimsIrn3450000000000 implements MigrationInterface {
|
||||||
|
name = "WidenEimsIrn3450000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ALTER COLUMN eims_irn TYPE text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ALTER COLUMN eims_irn TYPE varchar(64)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */
|
||||||
|
export class AddEimsSignedQr3460000000000 implements MigrationInterface {
|
||||||
|
name = "AddEimsSignedQr3460000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_signed_qr text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
DROP COLUMN IF EXISTS eims_signed_qr
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */
|
||||||
|
export class EimsCancellation3470000000000 implements MigrationInterface {
|
||||||
|
name = "EimsCancellation3470000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8),
|
||||||
|
ADD COLUMN IF NOT EXISTS eims_cancellation_remark text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
DROP COLUMN IF EXISTS eims_cancelled_at,
|
||||||
|
DROP COLUMN IF EXISTS eims_cancellation_date,
|
||||||
|
DROP COLUMN IF EXISTS eims_cancellation_reason_code,
|
||||||
|
DROP COLUMN IF EXISTS eims_cancellation_remark
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too
|
||||||
|
* (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same
|
||||||
|
* varchar(64) on the next successful registration.
|
||||||
|
*/
|
||||||
|
export class WidenPreviousIrn3480000000000 implements MigrationInterface {
|
||||||
|
name = "WidenPreviousIrn3480000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.eims_system_state
|
||||||
|
ALTER COLUMN previous_irn TYPE text
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.eims_system_state
|
||||||
|
ALTER COLUMN previous_irn TYPE varchar(64)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/** `freight.eims_receipts` — see `EimsReceipt` entity. */
|
||||||
|
export class EimsReceipts3490000000000 implements MigrationInterface {
|
||||||
|
name = "EimsReceipts3490000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.eims_receipts (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invoice_id uuid NOT NULL REFERENCES freight.invoices(id),
|
||||||
|
kind varchar(16) NOT NULL,
|
||||||
|
status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
|
||||||
|
receipt_number varchar(64) NOT NULL,
|
||||||
|
rrn text,
|
||||||
|
qr text,
|
||||||
|
ack_status varchar(8),
|
||||||
|
submitted_at timestamptz,
|
||||||
|
last_error jsonb,
|
||||||
|
request jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => {
|
|||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
|
{ get: () => undefined } as never, // config
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // config
|
||||||
);
|
);
|
||||||
return { service, defaultManager, txManager, transaction };
|
return { service, defaultManager, txManager, transaction };
|
||||||
};
|
};
|
||||||
@@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // config
|
||||||
);
|
);
|
||||||
return { service, manager };
|
return { service, manager };
|
||||||
};
|
};
|
||||||
@@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // config
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
@@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // config
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
@@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("BillingService.document", () => {
|
||||||
|
const invoiceRow = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: "inv-1",
|
||||||
|
invoiceNumber: "INV-20260812-00001",
|
||||||
|
source: "booking",
|
||||||
|
sourceId: "booking-1",
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
type: "freight",
|
||||||
|
currency: "ETB",
|
||||||
|
subtotalAmount: 100,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: 100,
|
||||||
|
paidAmount: 0,
|
||||||
|
balanceAmount: 100,
|
||||||
|
issuedAt: new Date(2026, 7, 12),
|
||||||
|
dueAt: new Date(2026, 7, 19),
|
||||||
|
eimsIrn: null,
|
||||||
|
eimsSignedQr: null,
|
||||||
|
company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const build = (invoice: Record<string, unknown>) => {
|
||||||
|
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||||
|
const service = new BillingService(
|
||||||
|
{} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||||
|
{ findAll: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ render } as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
get: (key: string) =>
|
||||||
|
key === "eims"
|
||||||
|
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
||||||
|
: undefined,
|
||||||
|
} as never, // config
|
||||||
|
);
|
||||||
|
return { service, render };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
||||||
|
const { service, render } = build(invoiceRow());
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined();
|
||||||
|
expect(model.qrImageUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the buyer's name, TIN and VAT number on every invoice", async () => {
|
||||||
|
const { service, render } = build(invoiceRow());
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" });
|
||||||
|
expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" });
|
||||||
|
expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the VAT row when the buyer company has none", async () => {
|
||||||
|
const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } }));
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows EDR's own seller TIN and VAT number from EIMS config", async () => {
|
||||||
|
const { service, render } = build(invoiceRow());
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" });
|
||||||
|
expect(model.summary).toContainEqual({
|
||||||
|
label: "Seller VAT No.",
|
||||||
|
value: "43256663343256663322",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => {
|
||||||
|
const { service, render } = build(
|
||||||
|
invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
const model = render.mock.calls[0][0];
|
||||||
|
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
||||||
|
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
@@ -12,6 +13,7 @@ import { logCtx } from "@edr/api-common";
|
|||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
@@ -161,6 +163,7 @@ export class BillingService {
|
|||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
private readonly files: FilesService,
|
private readonly files: FilesService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -384,7 +387,7 @@ export class BillingService {
|
|||||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const invoice = await this.findById(id);
|
const invoice = await this.findById(id);
|
||||||
return this.invoiceDocuments.render(
|
return this.invoiceDocuments.render(
|
||||||
this.toDocumentModel(invoice, "INVOICE"),
|
await this.toDocumentModel(invoice, "INVOICE"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,15 +400,24 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.invoiceDocuments.render(
|
return this.invoiceDocuments.render(
|
||||||
this.toDocumentModel(invoice, "RECEIPT"),
|
await this.toDocumentModel(invoice, "RECEIPT"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||||
private toDocumentModel(
|
private async toDocumentModel(
|
||||||
invoice: Invoice & { lines: InvoiceLine[] },
|
invoice: Invoice & { lines: InvoiceLine[] },
|
||||||
kind: "INVOICE" | "RECEIPT",
|
kind: "INVOICE" | "RECEIPT",
|
||||||
): InvoiceDocumentModel {
|
): Promise<InvoiceDocumentModel> {
|
||||||
const title = invoice.source
|
const title = invoice.source
|
||||||
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
||||||
: "EDR";
|
: "EDR";
|
||||||
@@ -423,6 +435,43 @@ export class BillingService {
|
|||||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||||
|
|
||||||
|
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 },
|
||||||
|
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
||||||
|
...(invoice.company?.vatNumber
|
||||||
|
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
||||||
|
: []),
|
||||||
|
{ label: "Status", value: invoice.status },
|
||||||
|
{ label: "Type", value: invoice.type },
|
||||||
|
{ label: "Reference", value: invoice.sourceId },
|
||||||
|
{ label: "Currency", value: invoice.currency },
|
||||||
|
{
|
||||||
|
label: "Issued",
|
||||||
|
value: invoice.issuedAt
|
||||||
|
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Due",
|
||||||
|
value: invoice.dueAt
|
||||||
|
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this
|
||||||
|
// codebase). Shown only when actually configured, same as the buyer VAT row.
|
||||||
|
const eimsCfg = this.config.get<EimsConfig>("eims");
|
||||||
|
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
|
||||||
|
if (eimsCfg?.invoice?.sellerVatNumber) {
|
||||||
|
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
||||||
|
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
title,
|
title,
|
||||||
@@ -430,24 +479,7 @@ export class BillingService {
|
|||||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||||
status: invoice.status,
|
status: invoice.status,
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
summary: [
|
summary,
|
||||||
{ label: "Status", value: invoice.status },
|
|
||||||
{ label: "Type", value: invoice.type },
|
|
||||||
{ label: "Reference", value: invoice.sourceId },
|
|
||||||
{ label: "Currency", value: invoice.currency },
|
|
||||||
{
|
|
||||||
label: "Issued",
|
|
||||||
value: invoice.issuedAt
|
|
||||||
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Due",
|
|
||||||
value: invoice.dueAt
|
|
||||||
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
categoryHeader: "Charge type",
|
categoryHeader: "Charge type",
|
||||||
lines: invoice.lines.map((l) => ({
|
lines: invoice.lines.map((l) => ({
|
||||||
description: l.description ?? l.chargeType,
|
description: l.description ?? l.chargeType,
|
||||||
@@ -458,6 +490,7 @@ export class BillingService {
|
|||||||
currency: l.currency,
|
currency: l.currency,
|
||||||
})),
|
})),
|
||||||
totals,
|
totals,
|
||||||
|
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
|
||||||
|
|
||||||
|
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
|
||||||
|
kind: "INVOICE",
|
||||||
|
title: "Freight",
|
||||||
|
documentNumber: "INV-20260812-00001",
|
||||||
|
issuedAt: new Date(2026, 7, 12),
|
||||||
|
status: "PENDING",
|
||||||
|
currency: "ETB",
|
||||||
|
summary: [{ label: "Status", value: "PENDING" }],
|
||||||
|
lines: [],
|
||||||
|
totals: [{ label: "Total", amount: 100, grand: true }],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
|
||||||
|
const service = new InvoiceDocumentService({} as never, {} as never);
|
||||||
|
|
||||||
|
it("renders no QR block when qrImageUrl is unset", () => {
|
||||||
|
const html = service.buildHtml(model());
|
||||||
|
expect(html).not.toContain('class="qr"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the QR image when qrImageUrl is set", () => {
|
||||||
|
const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
|
||||||
|
expect(html).toContain('class="qr"');
|
||||||
|
expect(html).toContain('src="data:image/png;base64,QR"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still shows the IRN text row via the ordinary summary grid", () => {
|
||||||
|
const html = service.buildHtml(
|
||||||
|
model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }),
|
||||||
|
);
|
||||||
|
expect(html).toContain("EIMS IRN");
|
||||||
|
expect(html).toContain("IRN-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("widens the summary's right margin only when a QR is present, to clear the QR block", () => {
|
||||||
|
// "summary-with-qr" also appears in the always-present <style> rule, so the check has to be
|
||||||
|
// the actual div's class attribute, not a bare substring match.
|
||||||
|
expect(service.buildHtml(model())).not.toContain('class="summary summary-with-qr"');
|
||||||
|
expect(service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }))).toContain(
|
||||||
|
'class="summary summary-with-qr"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,6 +62,12 @@ export interface InvoiceDocumentModel {
|
|||||||
* explicitly only to override that default for one document.
|
* explicitly only to override that default for one document.
|
||||||
*/
|
*/
|
||||||
stampImageUrl?: string | null;
|
stampImageUrl?: string | null;
|
||||||
|
/**
|
||||||
|
* MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` —
|
||||||
|
* see that column's comment). Set only once an invoice is actually registered; the IRN text
|
||||||
|
* itself goes through the ordinary `summary` rows, not a dedicated field.
|
||||||
|
*/
|
||||||
|
qrImageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,9 +102,11 @@ export class InvoiceDocumentService {
|
|||||||
// summary grid, line-item table, totals) from the model — not a flat
|
// summary grid, line-item table, totals) from the model — not a flat
|
||||||
// plain-text dump — so it still reads as a proper invoice document.
|
// plain-text dump — so it still reads as a proper invoice document.
|
||||||
// ponytail: still draws the plain vector seal, not the uploaded stamp
|
// ponytail: still draws the plain vector seal, not the uploaded stamp
|
||||||
// image — embedding a raster image needs a new PDF XObject primitive
|
// image, and omits the EIMS QR entirely — embedding a raster image
|
||||||
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
|
// needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade
|
||||||
// carry the real stamp too; today it's a rare degraded fallback.
|
// when the Chromium-less path needs to carry the real stamp/QR too;
|
||||||
|
// today it's a rare degraded fallback. The IRN text itself still
|
||||||
|
// comes through (buildFallbackPdf renders model.summary same as HTML).
|
||||||
fallback: () => this.buildFallbackPdf(resolvedModel),
|
fallback: () => this.buildFallbackPdf(resolvedModel),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -243,6 +251,10 @@ export class InvoiceDocumentService {
|
|||||||
const sealInner = sealMarkup(model.stampImageUrl, sealText);
|
const sealInner = sealMarkup(model.stampImageUrl, sealText);
|
||||||
const sealCssClass = sealClass(model.stampImageUrl);
|
const sealCssClass = sealClass(model.stampImageUrl);
|
||||||
|
|
||||||
|
const qrMarkup = model.qrImageUrl
|
||||||
|
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
const summaryRows = model.summary
|
const summaryRows = model.summary
|
||||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||||
.join("");
|
.join("");
|
||||||
@@ -281,8 +293,19 @@ export class InvoiceDocumentService {
|
|||||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
.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 { 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; }
|
||||||
${sealImageCss()}
|
${sealImageCss()}
|
||||||
|
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
|
||||||
|
.qr img { width: 90px; height: 90px; }
|
||||||
|
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
|
||||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
.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; }
|
/* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the
|
||||||
|
150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */
|
||||||
|
.summary.summary-with-qr { margin-right: 270px; }
|
||||||
|
/* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long
|
||||||
|
unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than
|
||||||
|
honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay
|
||||||
|
(confirmed by isolating the two: margin-right alone already positioned the box correctly;
|
||||||
|
the text itself was still escaping the box's own right edge until this was added). */
|
||||||
|
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; }
|
||||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||||
@@ -309,7 +332,8 @@ export class InvoiceDocumentService {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="${sealCssClass}">${sealInner}</div>
|
<div class="${sealCssClass}">${sealInner}</div>
|
||||||
<div class="summary">${summaryRows}</div>
|
${qrMarkup}
|
||||||
|
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
|||||||
salesPersonName: null,
|
salesPersonName: null,
|
||||||
transactionType: "B2B",
|
transactionType: "B2B",
|
||||||
payment: { mode: "CASH", term: "IMMIDIATE" },
|
payment: { mode: "CASH", term: "IMMIDIATE" },
|
||||||
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
|
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }),
|
||||||
natureOfSupplies: "Service",
|
natureOfSupplies: "Service",
|
||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
incomeWithholdValue: 0,
|
incomeWithholdValue: 0,
|
||||||
@@ -115,8 +115,8 @@ describe("toEimsInvoice", () => {
|
|||||||
context({
|
context({
|
||||||
taxForLine: (line) =>
|
taxForLine: (line) =>
|
||||||
line.chargeType === "RAIL_FREIGHT"
|
line.chargeType === "RAIL_FREIGHT"
|
||||||
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
|
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }
|
||||||
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
|
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50, discount: 25 },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,6 +130,7 @@ describe("toEimsInvoice", () => {
|
|||||||
TaxCode: "VAT15",
|
TaxCode: "VAT15",
|
||||||
TaxAmount: 1500,
|
TaxAmount: 1500,
|
||||||
ExciseTaxValue: 0,
|
ExciseTaxValue: 0,
|
||||||
|
Discount: 0,
|
||||||
TotalLineAmount: 11500,
|
TotalLineAmount: 11500,
|
||||||
Unit: "PCS",
|
Unit: "PCS",
|
||||||
NatureOfSupplies: "service",
|
NatureOfSupplies: "service",
|
||||||
@@ -141,6 +142,9 @@ describe("toEimsInvoice", () => {
|
|||||||
TaxCode: "EXEMPT",
|
TaxCode: "EXEMPT",
|
||||||
TaxAmount: 0,
|
TaxAmount: 0,
|
||||||
ExciseTaxValue: 50,
|
ExciseTaxValue: 50,
|
||||||
|
// Discount is carried on the line but does not (yet) reduce TotalLineAmount — see the
|
||||||
|
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
|
||||||
|
Discount: 25,
|
||||||
TotalLineAmount: 1050,
|
TotalLineAmount: 1050,
|
||||||
Unit: "CTR",
|
Unit: "CTR",
|
||||||
});
|
});
|
||||||
@@ -187,7 +191,17 @@ describe("toEimsInvoice", () => {
|
|||||||
toEimsInvoice(
|
toEimsInvoice(
|
||||||
invoice(),
|
invoice(),
|
||||||
seller,
|
seller,
|
||||||
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
|
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0, discount: 0 }) }),
|
||||||
|
),
|
||||||
|
).toThrow(/unresolved tax treatment for line 1/);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(
|
||||||
|
invoice(),
|
||||||
|
seller,
|
||||||
|
context({
|
||||||
|
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: NaN }),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
).toThrow(/unresolved tax treatment for line 1/);
|
).toThrow(/unresolved tax treatment for line 1/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ export interface EimsLineTax {
|
|||||||
code: string;
|
code: string;
|
||||||
ratePercent: number;
|
ratePercent: number;
|
||||||
exciseTaxValue: number;
|
exciseTaxValue: number;
|
||||||
|
/**
|
||||||
|
* Line-level `Discount`. Its effect on `TotalLineAmount` has never been observed live (every
|
||||||
|
* prior test ran it at 0), so the total below still sums PreTax + Tax + Excise only — do not
|
||||||
|
* start subtracting this without a confirmed MoR example.
|
||||||
|
*/
|
||||||
|
discount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EimsMapperContext {
|
export interface EimsMapperContext {
|
||||||
@@ -336,7 +342,13 @@ export function toEimsInvoice(
|
|||||||
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
|
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
|
||||||
const lineNumber = index + 1;
|
const lineNumber = index + 1;
|
||||||
const tax = context.taxForLine(line, lineNumber);
|
const tax = context.taxForLine(line, lineNumber);
|
||||||
if (!tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue)) {
|
if (
|
||||||
|
!tax ||
|
||||||
|
!tax.code ||
|
||||||
|
!Number.isFinite(tax.ratePercent) ||
|
||||||
|
!Number.isFinite(tax.exciseTaxValue) ||
|
||||||
|
!Number.isFinite(tax.discount)
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
|
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
|
||||||
`on invoice ${invoice.invoiceNumber}`,
|
`on invoice ${invoice.invoiceNumber}`,
|
||||||
@@ -349,7 +361,7 @@ export function toEimsInvoice(
|
|||||||
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
|
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Discount: 0,
|
Discount: round2(tax.discount),
|
||||||
ExciseTaxValue,
|
ExciseTaxValue,
|
||||||
HarmonizationCode: null,
|
HarmonizationCode: null,
|
||||||
NatureOfSupplies: natureOfSupplies,
|
NatureOfSupplies: natureOfSupplies,
|
||||||
|
|||||||
@@ -111,10 +111,22 @@ export class Invoice extends BaseEntity {
|
|||||||
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
||||||
eimsStatus!: EimsInvoiceStatus;
|
eimsStatus!: EimsInvoiceStatus;
|
||||||
|
|
||||||
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
|
/**
|
||||||
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
|
* Invoice Reference Number returned by EIMS. Unique across invoices (partial index). `text`,
|
||||||
|
* not a fixed varchar — MoR has never documented an IRN format/length, and a real live value
|
||||||
|
* (a `test-` prefix + 64 hex chars, 69 chars total) already overflowed a prior varchar(64).
|
||||||
|
*/
|
||||||
|
@Column({ name: "eims_irn", type: "text", nullable: true })
|
||||||
eimsIrn?: string | null;
|
eimsIrn?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `signedQR` from the register response — a base64 PNG image, already rendered by MoR (confirmed
|
||||||
|
* against the Postman collection's saved response: decodes to a PNG magic-byte header). Stored
|
||||||
|
* verbatim; `BillingService.renderEimsQr` only wraps it in a `data:image/png;base64,` URL.
|
||||||
|
*/
|
||||||
|
@Column({ name: "eims_signed_qr", type: "text", nullable: true })
|
||||||
|
eimsSignedQr?: string | null;
|
||||||
|
|
||||||
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
|
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
|
||||||
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
|
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
|
||||||
eimsDocumentNumber?: string | null;
|
eimsDocumentNumber?: string | null;
|
||||||
@@ -133,4 +145,24 @@ export class Invoice extends BaseEntity {
|
|||||||
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
|
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
|
||||||
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
|
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
|
||||||
eimsLastError?: EimsInvoiceError | null;
|
eimsLastError?: EimsInvoiceError | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/cancel` — set together, only once `eimsStatus` reaches CANCELLED.
|
||||||
|
* `eimsCancelledAt` is our own server time (same convention as `eimsSubmittedAt`);
|
||||||
|
* `eimsCancellationDate` is MoR's own confirmation string, stored verbatim like `eimsAckDate` —
|
||||||
|
* its format (`"Sun Dec 22 21:55:03 EAT 2024"`, a Java `Date#toString()`) is not reliably
|
||||||
|
* `Date.parse`-able (the `EAT` zone abbreviation is non-standard), so it is never parsed.
|
||||||
|
*/
|
||||||
|
@Column({ name: "eims_cancelled_at", type: "timestamptz", nullable: true })
|
||||||
|
eimsCancelledAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "eims_cancellation_date", type: "varchar", length: 64, nullable: true })
|
||||||
|
eimsCancellationDate?: string | null;
|
||||||
|
|
||||||
|
/** Numeric string per the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
|
||||||
|
@Column({ name: "eims_cancellation_reason_code", type: "varchar", length: 8, nullable: true })
|
||||||
|
eimsCancellationReasonCode?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
|
||||||
|
eimsCancellationRemark?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
/** `POST /v1/cancel` body — `ReasonCode`/`Remark` are the collection's own field names. */
|
||||||
|
export class CancelEimsRegistrationDto {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/receipt/sales` — invoice/amounts/currency/IRN are derived from the invoice; everything
|
||||||
|
* here is what this codebase has no source of truth for, so it is asked of the caller rather than
|
||||||
|
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
|
||||||
|
*/
|
||||||
|
export class RegisterSalesReceiptDto {
|
||||||
|
@ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." })
|
||||||
|
@IsIn(EIMS_MODE_OF_PAYMENT)
|
||||||
|
modeOfPayment!: EimsModeOfPayment;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Overrides the amount collected; defaults to the invoice's paidAmount." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
collectedAmount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "ETB, USD or CAD. Defaults to the invoice's currency." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Required when currency is not ETB." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
exchangeRate?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Defaults to "FULL" if the invoice balance is 0, else "PARTIAL".' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
paymentCoverage?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 200)
|
||||||
|
collectorName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 200)
|
||||||
|
paymentServiceProvider?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 200)
|
||||||
|
otherPaymentServiceProviderName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
accountNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
transactionNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
chequeNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
cpoNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 100)
|
||||||
|
documentNumber?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsNumber, IsOptional, IsString, Length } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/receipt/withholding`. `type`/`preTaxAmount`/`withholdingAmount` are real money/tax
|
||||||
|
* values this codebase has no computed source for (the same "no tax model" gap the invoice mapper
|
||||||
|
* documents) — required from the caller rather than defaulted or derived.
|
||||||
|
*/
|
||||||
|
export class RegisterWithholdingReceiptDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'WithholdDetail.Type. Only "TWHT" has been observed; not restricted to it.',
|
||||||
|
example: "TWHT",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 16)
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
rate?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Amount the withholding is calculated against." })
|
||||||
|
@IsNumber()
|
||||||
|
preTaxAmount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "The withheld amount itself." })
|
||||||
|
@IsNumber()
|
||||||
|
withholdingAmount!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Defaults to "Withholding".' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Required when the invoice currency is not ETB." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
exchangeRate?: number;
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@ export class ResolveEimsRegistrationDto {
|
|||||||
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
|
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
|
||||||
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||||
})
|
})
|
||||||
|
// Not capped at 64: eims_irn is `text` now — a real IRN already overflowed a former
|
||||||
|
// varchar(64) (69 chars). This bound is a sanity ceiling, not a confirmed MoR format.
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(1, 64)
|
@Length(1, 500)
|
||||||
irn?: string;
|
irn?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
|
||||||
|
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||||
|
|
||||||
|
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||||
|
({
|
||||||
|
id: INVOICE_ID,
|
||||||
|
invoiceNumber: "INV-20260807-00042",
|
||||||
|
companyId: "company-1",
|
||||||
|
eimsStatus: EimsInvoiceStatus.Registered,
|
||||||
|
eimsIrn: IRN,
|
||||||
|
eimsCancelledAt: null,
|
||||||
|
eimsCancellationDate: null,
|
||||||
|
eimsCancellationReasonCode: null,
|
||||||
|
eimsCancellationRemark: null,
|
||||||
|
...over,
|
||||||
|
}) as unknown as Invoice;
|
||||||
|
|
||||||
|
/** In-memory stand-in, same shape as the registration spec's FakeDb but with only what cancel needs. */
|
||||||
|
class FakeDb {
|
||||||
|
invoices = new Map<string, Invoice>();
|
||||||
|
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||||
|
|
||||||
|
constructor(invoices: Invoice[]) {
|
||||||
|
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private manager = {
|
||||||
|
createQueryBuilder: (entity: unknown) => {
|
||||||
|
let id: string | undefined;
|
||||||
|
const builder = {
|
||||||
|
setLock: () => builder,
|
||||||
|
where: (_clause: string, params: Record<string, string>) => {
|
||||||
|
id = params.invoiceId;
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
getOne: async () => (entity === Invoice ? (this.invoices.get(id!) ?? null) : null),
|
||||||
|
};
|
||||||
|
return builder;
|
||||||
|
},
|
||||||
|
findOne: async (_entity: unknown, options: { where: { id: string } }) =>
|
||||||
|
this.invoices.get(options.where.id) ?? null,
|
||||||
|
update: async (_entity: unknown, id: string, patch: Record<string, unknown>) => {
|
||||||
|
Object.assign(this.invoices.get(id)!, patch);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
asDataSource(): DataSource {
|
||||||
|
return {
|
||||||
|
manager: this.manager,
|
||||||
|
query: async () => (this.companyContact ? [this.companyContact] : []),
|
||||||
|
transaction: async (body: (m: unknown) => Promise<unknown>) => body(this.manager),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (db: FakeDb, postBearer: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) =>
|
||||||
|
new EimsCancellationService(
|
||||||
|
db.asDataSource(),
|
||||||
|
{ postBearer } as unknown as EimsClientService,
|
||||||
|
{ directSend } as unknown as NotificationsService,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("EimsCancellationService.cancelInvoiceWithEims", () => {
|
||||||
|
it("cancels a registered invoice and persists MoR's confirmation", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ statusCode: 200, message: "Success", body: { cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" } });
|
||||||
|
|
||||||
|
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1", "Duplicate");
|
||||||
|
|
||||||
|
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "Duplicate" });
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
expect(view.eimsCancellationDate).toBe("Sun Dec 22 21:55:03 EAT 2024");
|
||||||
|
expect(view.eimsCancellationReasonCode).toBe("1");
|
||||||
|
expect(view.eimsCancellationRemark).toBe("Duplicate");
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsCancelledAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults Remark to an empty string, matching the collection's request shape", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
|
||||||
|
|
||||||
|
await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
|
||||||
|
|
||||||
|
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
|
||||||
|
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to cancel an invoice that was never registered", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a MoR rejection and leaves the invoice REGISTERED", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS cancel failed (406)", 406));
|
||||||
|
|
||||||
|
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
|
||||||
|
EimsApiException,
|
||||||
|
);
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the buyer company on success, without blocking the result", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
|
||||||
|
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
|
||||||
|
|
||||||
|
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
|
||||||
|
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("cancelled"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fail cancellation when the buyer notification itself fails", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: null };
|
||||||
|
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
|
||||||
|
|
||||||
|
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
|
||||||
|
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
|
|
||||||
|
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 { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/cancel` for one already-registered invoice.
|
||||||
|
*
|
||||||
|
* Simpler than registration: cancellation carries no `InvoiceCounter`/`DocumentNumber`, so none of
|
||||||
|
* `EimsSystemState`'s reservation machinery applies, and — unlike registration — a retried cancel
|
||||||
|
* is safe: the collection's bulk-cancel example shows MoR itself rejects a second cancel with
|
||||||
|
* "IRN already Canceled.", so there is no double-filing risk the way an unacknowledged register
|
||||||
|
* call has. That is what makes the simpler shape below correct: no system-wide block, no in-flight
|
||||||
|
* marker, just a lock-check-unlock before the call and a fresh lock-check-write after it.
|
||||||
|
*
|
||||||
|
* ponytail: the eligibility check (TX1) and the write (TX2) are not one atomic operation, so two
|
||||||
|
* concurrent cancels on the same invoice could both pass TX1 and both call MoR — wasted, but safe,
|
||||||
|
* per the paragraph above. Upgrade to a single locked reservation (like registration's) only if
|
||||||
|
* MoR's cancel endpoint turns out not to be idempotent after all.
|
||||||
|
*
|
||||||
|
* Bearer-authenticated but unsigned (`postBearer`), same as `/v1/verify` — the collection's saved
|
||||||
|
* `/v1/cancel` request carries no `{request,signature,certificate}` envelope.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsCancellationService {
|
||||||
|
private readonly logger = new Logger(EimsCancellationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly client: EimsClientService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent: an already-cancelled invoice returns unchanged, no HTTP call. Refuses an invoice
|
||||||
|
* that was never registered — there is no IRN to cancel.
|
||||||
|
*/
|
||||||
|
async cancelInvoiceWithEims(
|
||||||
|
invoiceId: string,
|
||||||
|
reasonCode: string,
|
||||||
|
remark?: string,
|
||||||
|
): Promise<EimsInvoiceStatusView> {
|
||||||
|
const eligible = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||||
|
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return null;
|
||||||
|
if (!invoice.eimsIrn) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_NOT_REGISTERED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
|
});
|
||||||
|
if (!eligible) return this.getEimsCancellationStatus(invoiceId);
|
||||||
|
|
||||||
|
const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" };
|
||||||
|
// Outside any transaction — no DB lock is held across the wire.
|
||||||
|
const response = await this.client.postBearer<EimsCancelRequest, EimsCancelResponse>(
|
||||||
|
"/v1/cancel",
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
const cancellationDate = response?.body?.cancellationDate ?? null;
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||||
|
// Re-checked under lock: a concurrent call may have already recorded this cancellation.
|
||||||
|
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return;
|
||||||
|
await manager.update(Invoice, invoiceId, {
|
||||||
|
eimsStatus: EimsInvoiceStatus.Cancelled,
|
||||||
|
eimsCancelledAt: new Date(),
|
||||||
|
eimsCancellationDate: cancellationDate,
|
||||||
|
eimsCancellationReasonCode: reasonCode,
|
||||||
|
eimsCancellationRemark: remark ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.logger.log(`Invoice ${eligible.invoiceNumber} cancelled with EIMS (IRN ${eligible.eimsIrn})`);
|
||||||
|
|
||||||
|
await this.notifyBuyer(eligible);
|
||||||
|
return this.getEimsCancellationStatus(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`);
|
||||||
|
return toEimsInvoiceStatusView(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort — a notification failure must never mask a cancellation that already succeeded. */
|
||||||
|
private async notifyBuyer(invoice: Invoice): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sendCompanyChannels(
|
||||||
|
this.dataSource,
|
||||||
|
this.notifications,
|
||||||
|
invoice.companyId,
|
||||||
|
`Invoice ${invoice.invoiceNumber} has been cancelled with MoR EIMS.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||||
|
const invoice = await manager
|
||||||
|
.createQueryBuilder(Invoice, "invoice")
|
||||||
|
.setLock("pessimistic_write")
|
||||||
|
.where("invoice.id = :invoiceId", { invoiceId })
|
||||||
|
.getOne();
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||||
|
import { eimsConfig } from "./eims-test-fixtures";
|
||||||
|
|
||||||
|
const SESSION = { systemNumber: "B0360154BA", systemType: "SYS" };
|
||||||
|
|
||||||
|
describe("assertEimsInvoiceConfig — charge-type overrides", () => {
|
||||||
|
it("passes when EIMS_TAX_CODE_BY_CHARGE_TYPE and _RATE_ list the same charge types", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "0" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a charge type has a code but no rate, or a rate but no code", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/mismatched: RAIL_FREIGHT/);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCodeByChargeType: {},
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "0" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/mismatched: RAIL_FREIGHT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a rate/excise/discount override is not a number", () => {
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "not-a-number" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/EIMS_TAX_RATE_BY_CHARGE_TYPE\[RAIL_FREIGHT\]/);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
assertEimsInvoiceConfig(
|
||||||
|
eimsConfig({ invoice: { ...eimsConfig().invoice, discountByChargeType: { X: "abc" } } }),
|
||||||
|
),
|
||||||
|
).toThrow(/EIMS_DISCOUNT_BY_CHARGE_TYPE\[X\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildEimsContext — taxForLine", () => {
|
||||||
|
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
|
||||||
|
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
|
||||||
|
|
||||||
|
it("uses the per-chargeType override when one is configured", () => {
|
||||||
|
const context = buildEimsContext(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCode: "VAT15",
|
||||||
|
taxRatePercent: 15,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "0" },
|
||||||
|
exciseByChargeType: { RAIL_FREIGHT: "5" },
|
||||||
|
discountByChargeType: { RAIL_FREIGHT: "10" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(context.taxForLine(line("RAIL_FREIGHT"), 1)).toEqual({
|
||||||
|
code: "VAT0",
|
||||||
|
ratePercent: 0,
|
||||||
|
exciseTaxValue: 5,
|
||||||
|
discount: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the invoice-wide default for a charge type with no override", () => {
|
||||||
|
const context = buildEimsContext(
|
||||||
|
eimsConfig({
|
||||||
|
invoice: {
|
||||||
|
...eimsConfig().invoice,
|
||||||
|
taxCode: "VAT15",
|
||||||
|
taxRatePercent: 15,
|
||||||
|
exciseTaxValue: 0,
|
||||||
|
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
|
||||||
|
taxRateByChargeType: { RAIL_FREIGHT: "0" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(context.taxForLine(line("HANDLING"), 1)).toEqual({
|
||||||
|
code: "VAT15",
|
||||||
|
ratePercent: 15,
|
||||||
|
exciseTaxValue: 0,
|
||||||
|
discount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,47 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertSellerFormats(config.invoice);
|
assertSellerFormats(config.invoice);
|
||||||
|
assertChargeTypeOverrides(config.invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-`chargeType` tax overrides must be internally consistent before anything is filed:
|
||||||
|
* `EIMS_TAX_CODE_BY_CHARGE_TYPE` and `EIMS_TAX_RATE_BY_CHARGE_TYPE` must name the same charge
|
||||||
|
* types (a code with no rate, or vice versa, is a half-finished override), and every rate/excise/
|
||||||
|
* discount value must parse as a number — checked once here rather than once per line at
|
||||||
|
* registration time.
|
||||||
|
*/
|
||||||
|
function assertChargeTypeOverrides(invoice: EimsConfig["invoice"]): void {
|
||||||
|
const codeKeys = Object.keys(invoice.taxCodeByChargeType);
|
||||||
|
const rateKeys = Object.keys(invoice.taxRateByChargeType);
|
||||||
|
const mismatched = [...new Set([...codeKeys, ...rateKeys])].filter(
|
||||||
|
(k) => !(codeKeys.includes(k) && rateKeys.includes(k)),
|
||||||
|
);
|
||||||
|
if (mismatched.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_INVOICE_CONFIG_INVALID",
|
||||||
|
message:
|
||||||
|
`EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge ` +
|
||||||
|
`types; mismatched: ${mismatched.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericMaps: { env: string; map: Record<string, string> }[] = [
|
||||||
|
{ env: "EIMS_TAX_RATE_BY_CHARGE_TYPE", map: invoice.taxRateByChargeType },
|
||||||
|
{ env: "EIMS_EXCISE_BY_CHARGE_TYPE", map: invoice.exciseByChargeType },
|
||||||
|
{ env: "EIMS_DISCOUNT_BY_CHARGE_TYPE", map: invoice.discountByChargeType },
|
||||||
|
];
|
||||||
|
const badNumbers = numericMaps.flatMap(({ env, map }) =>
|
||||||
|
Object.entries(map)
|
||||||
|
.filter(([, value]) => !Number.isFinite(Number(value)))
|
||||||
|
.map(([chargeType]) => `${env}[${chargeType}]`),
|
||||||
|
);
|
||||||
|
if (badNumbers.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_INVOICE_CONFIG_INVALID",
|
||||||
|
message: `EIMS charge-type overrides must be numbers: ${badNumbers.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,9 +176,25 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
|||||||
salesPersonName: invoice.salesPersonName,
|
salesPersonName: invoice.salesPersonName,
|
||||||
transactionType: invoice.transactionType,
|
transactionType: invoice.transactionType,
|
||||||
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
||||||
// One treatment for every line today. The mapper resolves tax per line, so a future
|
// Per-`chargeType` override when one is configured (validated symmetric in
|
||||||
// charge-type-specific rule slots in here without touching the mapper.
|
// assertChargeTypeOverrides), else the single invoice-wide default.
|
||||||
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }),
|
taxForLine: (line: EimsMapperLine) => {
|
||||||
|
const { chargeType } = line;
|
||||||
|
const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode;
|
||||||
|
const rate =
|
||||||
|
chargeType in invoice.taxRateByChargeType
|
||||||
|
? Number(invoice.taxRateByChargeType[chargeType])
|
||||||
|
: ratePercent;
|
||||||
|
const excise =
|
||||||
|
chargeType in invoice.exciseByChargeType
|
||||||
|
? Number(invoice.exciseByChargeType[chargeType])
|
||||||
|
: exciseTaxValue;
|
||||||
|
const discount =
|
||||||
|
chargeType in invoice.discountByChargeType
|
||||||
|
? Number(invoice.discountByChargeType[chargeType])
|
||||||
|
: 0;
|
||||||
|
return { code, ratePercent: rate, exciseTaxValue: excise, discount };
|
||||||
|
},
|
||||||
natureOfSupplies: invoice.natureOfSupplies,
|
natureOfSupplies: invoice.natureOfSupplies,
|
||||||
unitDefault: invoice.unitDefault,
|
unitDefault: invoice.unitDefault,
|
||||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||||
@@ -145,6 +202,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
|||||||
buyerCountryCode: invoice.buyerCountryCode,
|
buyerCountryCode: invoice.buyerCountryCode,
|
||||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||||
|
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||||
|
buyerIdType: invoice.buyerIdType,
|
||||||
|
buyerIdNumber: invoice.buyerIdNumber,
|
||||||
exchangeRate: input.exchangeRate ?? null,
|
exchangeRate: input.exchangeRate ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Invoice } from "../billing/entities/invoice.entity";
|
|||||||
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||||
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsApiException } from "./eims.errors";
|
import { EimsApiException } from "./eims.errors";
|
||||||
@@ -85,6 +86,8 @@ class FakeDb {
|
|||||||
state: EimsSystemState | null = null;
|
state: EimsSystemState | null = null;
|
||||||
/** Runs before every transaction body, to simulate a concurrent writer. */
|
/** Runs before every transaction body, to simulate a concurrent writer. */
|
||||||
onTransaction: (() => void) | null = null;
|
onTransaction: (() => void) | null = null;
|
||||||
|
/** `sendCompanyChannels`'s contact lookup, when a test needs it non-empty. */
|
||||||
|
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||||
|
|
||||||
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
|
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
|
||||||
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||||
@@ -133,10 +136,13 @@ class FakeDb {
|
|||||||
return {
|
return {
|
||||||
manager: this.manager,
|
manager: this.manager,
|
||||||
getRepository: this.manager.getRepository,
|
getRepository: this.manager.getRepository,
|
||||||
query: async (sql: string) =>
|
query: async (sql: string) => {
|
||||||
sql.includes("eims_system_state")
|
if (sql.includes("eims_system_state")) {
|
||||||
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
|
return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }];
|
||||||
: LINES,
|
}
|
||||||
|
if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : [];
|
||||||
|
return LINES;
|
||||||
|
},
|
||||||
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
||||||
this.onTransaction?.();
|
this.onTransaction?.();
|
||||||
return body(this.manager);
|
return body(this.manager);
|
||||||
@@ -155,6 +161,7 @@ const build = (
|
|||||||
postBearer: jest.Mock = jest.fn(),
|
postBearer: jest.Mock = jest.fn(),
|
||||||
getSessionContext: jest.Mock | undefined = undefined,
|
getSessionContext: jest.Mock | undefined = undefined,
|
||||||
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||||
|
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||||
) =>
|
) =>
|
||||||
new EimsInvoiceRegistrationService(
|
new EimsInvoiceRegistrationService(
|
||||||
db.asDataSource(),
|
db.asDataSource(),
|
||||||
@@ -164,6 +171,7 @@ const build = (
|
|||||||
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
|
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
|
||||||
} as unknown as EimsAuthService,
|
} as unknown as EimsAuthService,
|
||||||
{ notify } as unknown as NotificationInboxService,
|
{ notify } as unknown as NotificationInboxService,
|
||||||
|
{ directSend } as unknown as NotificationsService,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,8 +204,11 @@ const verifyResponse = (over: Record<string, unknown> = {}) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const okResponse = (irn = IRN) =>
|
const okResponse = (irn = IRN, over: Record<string, unknown> = {}) => ({
|
||||||
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
|
statusCode: 200,
|
||||||
|
message: "SUCCESS",
|
||||||
|
body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over },
|
||||||
|
});
|
||||||
|
|
||||||
const apiError = (kind: string, status?: number) =>
|
const apiError = (kind: string, status?: number) =>
|
||||||
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
|
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
|
||||||
@@ -226,6 +237,51 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists signedQR alongside the IRN", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse(IRN, { signedQR: "signed-qr-payload" }));
|
||||||
|
|
||||||
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(view.eimsSignedQr).toBe("signed-qr-payload");
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsSignedQr).toBe("signed-qr-payload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves eimsSignedQr null when the gateway does not return one", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(view.eimsSignedQr).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the buyer company on a successful registration, without blocking the result", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
|
||||||
|
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
|
||||||
|
.registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||||
|
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining(IRN));
|
||||||
|
expect(directSend).toHaveBeenCalledWith("email", "buyer@abc.et", expect.stringContaining(IRN));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fail registration when the buyer notification itself fails", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: null };
|
||||||
|
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
|
||||||
|
.registerInvoiceWithEims(INVOICE_ID);
|
||||||
|
|
||||||
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||||
|
});
|
||||||
|
|
||||||
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
|
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
|
||||||
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
|
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
|
||||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
@@ -408,7 +464,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
expect(postSigned).toHaveBeenCalledTimes(1);
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
|
it("returns both the counter and the document number after a refusal, keeps both after an ambiguous result", async () => {
|
||||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||||
const postSigned = jest
|
const postSigned = jest
|
||||||
.fn()
|
.fn()
|
||||||
@@ -421,14 +477,47 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
);
|
);
|
||||||
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
||||||
|
|
||||||
// The two numbers move differently, because MoR constrains them differently: the counter must
|
// A deterministic rejection rolls both numbers back — MoR's expected-next-value for either
|
||||||
// not skip (it returns), the document number must not repeat (it is burned).
|
// sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter).
|
||||||
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||||
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
|
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
|
||||||
expect(first.SourceSystem.InvoiceCounter).toBe(7);
|
expect(first.SourceSystem.InvoiceCounter).toBe(7);
|
||||||
expect(second.SourceSystem.InvoiceCounter).toBe(7);
|
expect(second.SourceSystem.InvoiceCounter).toBe(7);
|
||||||
expect(first.DocumentDetails.DocumentNumber).toBe("5");
|
expect(first.DocumentDetails.DocumentNumber).toBe("5");
|
||||||
expect(second.DocumentDetails.DocumentNumber).toBe("6");
|
expect(second.DocumentDetails.DocumentNumber).toBe("5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks and alerts with the real IRN when MoR accepts but persisting it locally fails", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const notify = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
// 1st/2nd calls are the reserve() updates; the 3rd is settleSuccess's invoice update — the one
|
||||||
|
// that actually failed live (eims_irn too narrow for the real value MoR returned).
|
||||||
|
let call = 0;
|
||||||
|
const manager = (db as unknown as { manager: { update: jest.Mock } }).manager;
|
||||||
|
const realUpdate = manager.update;
|
||||||
|
manager.update = jest.fn(async (entity: unknown, id: string, patch: Record<string, unknown>) => {
|
||||||
|
call++;
|
||||||
|
if (call === 3) throw new Error("value too long for type character varying(64)");
|
||||||
|
return realUpdate(entity, id, patch);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
||||||
|
INVOICE_ID,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/value too long/);
|
||||||
|
|
||||||
|
// The IRN is never lost, even though the normal success path never committed.
|
||||||
|
expect(db.state?.blockedReason).toContain(IRN);
|
||||||
|
expect(db.state?.blockedReason).toContain("ACCEPTED");
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Submitting);
|
||||||
|
|
||||||
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
|
const sent = notify.mock.calls[0][0];
|
||||||
|
expect(sent.priority).toBe("HIGH");
|
||||||
|
expect(sent.body).toContain(IRN);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
} from "../billing/eims-invoice.mapper";
|
} from "../billing/eims-invoice.mapper";
|
||||||
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
|
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
|
||||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
|
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
@@ -78,6 +81,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
private readonly client: EimsClientService,
|
private readonly client: EimsClientService,
|
||||||
private readonly auth: EimsAuthService,
|
private readonly auth: EimsAuthService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get cfg(): EimsConfig {
|
private get cfg(): EimsConfig {
|
||||||
@@ -115,17 +119,28 @@ export class EimsInvoiceRegistrationService {
|
|||||||
|
|
||||||
let irn: string;
|
let irn: string;
|
||||||
let ackDate: string | undefined;
|
let ackDate: string | undefined;
|
||||||
|
let signedQR: string | undefined;
|
||||||
try {
|
try {
|
||||||
// Deliberately outside every transaction — no DB lock is held across the wire.
|
// Deliberately outside every transaction — no DB lock is held across the wire.
|
||||||
const result = await this.submit(request);
|
const result = await this.submit(request);
|
||||||
irn = result.irn;
|
irn = result.irn;
|
||||||
ackDate = result.ackDate;
|
ackDate = result.ackDate;
|
||||||
|
signedQR = result.signedQR;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.settleFailure(invoiceId, reservation, err);
|
await this.settleFailure(invoiceId, reservation, err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
|
try {
|
||||||
|
await this.settleSuccess(invoiceId, reservation, irn, ackDate, signedQR);
|
||||||
|
} catch (err) {
|
||||||
|
// MoR already accepted this document — unlike settleFailure's targets, this is not
|
||||||
|
// ambiguous, it is a *known* IRN we simply failed to persist (confirmed live 2026-08-12: a
|
||||||
|
// column too narrow for a real IRN). Losing it here would be worse than the persistence
|
||||||
|
// bug itself, so it goes straight into the block reason and the alert, not just a log line.
|
||||||
|
await this.blockOnKnownIrnPersistFailure(invoiceId, reservation, irn, ackDate, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
|
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
|
||||||
);
|
);
|
||||||
@@ -373,13 +388,19 @@ export class EimsInvoiceRegistrationService {
|
|||||||
reservation: Reservation,
|
reservation: Reservation,
|
||||||
irn: string,
|
irn: string,
|
||||||
ackDate?: string,
|
ackDate?: string,
|
||||||
|
signedQR?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
let companyId: string | undefined;
|
||||||
|
let invoiceNumber = invoiceId;
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.lockInvoice(manager, invoiceId);
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||||
|
companyId = invoice.companyId;
|
||||||
|
invoiceNumber = invoice.invoiceNumber;
|
||||||
await manager.update(Invoice, invoiceId, {
|
await manager.update(Invoice, invoiceId, {
|
||||||
eimsStatus: EimsInvoiceStatus.Registered,
|
eimsStatus: EimsInvoiceStatus.Registered,
|
||||||
eimsIrn: irn,
|
eimsIrn: irn,
|
||||||
eimsAckDate: ackDate ?? null,
|
eimsAckDate: ackDate ?? null,
|
||||||
|
eimsSignedQr: signedQR ?? null,
|
||||||
eimsLastError: null,
|
eimsLastError: null,
|
||||||
});
|
});
|
||||||
await manager.update(EimsSystemState, reservation.stateId, {
|
await manager.update(EimsSystemState, reservation.stateId, {
|
||||||
@@ -390,20 +411,39 @@ export class EimsInvoiceRegistrationService {
|
|||||||
blockedReason: null,
|
blockedReason: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Best-effort, outside the transaction: MoR checklist ADD-N001 wants the buyer notified of a
|
||||||
|
// registration event. Never lets a notification failure mask a filing that already succeeded.
|
||||||
|
if (companyId) {
|
||||||
|
try {
|
||||||
|
await sendCompanyChannels(
|
||||||
|
this.dataSource,
|
||||||
|
this.notifications,
|
||||||
|
companyId,
|
||||||
|
`Invoice ${invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`EIMS buyer notification failed for invoice ${invoiceId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
|
* TX2b. A deterministic rejection releases the reservation **and returns both numbers**; an
|
||||||
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
|
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
|
||||||
* for every later document.
|
* for every later document.
|
||||||
*
|
*
|
||||||
* The two numbers move differently, because MoR constrains them differently:
|
* Both `InvoiceCounter` and `DocumentNumber` roll back together on a deterministic rejection —
|
||||||
|
* MoR's own expected-next-value only advances on acceptance, for both fields:
|
||||||
*
|
*
|
||||||
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
|
* - `InvoiceCounter`: "Invoice counter is not correct. expected : 1".
|
||||||
* document MoR definitively refused was never counted there, so ours must not advance either.
|
* - `DocumentNumber`: "Document number error. Document number is not in correct sequence
|
||||||
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
|
* expected : 1" (rule 7001) — confirmed live 2026-08-12. An earlier design burned
|
||||||
* unique". It is therefore spent by the attempt itself and never handed back, even for a
|
* `DocumentNumber` forward on every attempt, reasoning from a separate "Document number is
|
||||||
* refusal.
|
* not unique" rejection; that turned out to describe the same constraint from the other side
|
||||||
|
* (MoR rejects both reuse *and* skipping ahead of its true next value), and burning forward on
|
||||||
|
* every rejection permanently drifted past what MoR would ever accept again — confirmed live
|
||||||
|
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
|
||||||
*
|
*
|
||||||
* An ambiguous result keeps both: MoR may have counted and stored the document.
|
* An ambiguous result keeps both: MoR may have counted and stored the document.
|
||||||
*/
|
*/
|
||||||
@@ -434,9 +474,9 @@ export class EimsInvoiceRegistrationService {
|
|||||||
reservation.stateId,
|
reservation.stateId,
|
||||||
deterministic
|
deterministic
|
||||||
? {
|
? {
|
||||||
// Counter returns (MoR never counted a refused document); the document number does
|
// Both return: MoR never counted a refused document against either sequence.
|
||||||
// not (MoR requires it to be unique, so it is burned by the attempt).
|
|
||||||
nextInvoiceCounter: reservation.invoiceCounter,
|
nextInvoiceCounter: reservation.invoiceCounter,
|
||||||
|
nextDocumentNumber: Number(reservation.documentNumber),
|
||||||
inFlightInvoiceId: null,
|
inFlightInvoiceId: null,
|
||||||
inFlightCounter: null,
|
inFlightCounter: null,
|
||||||
inFlightDocumentNumber: null,
|
inFlightDocumentNumber: null,
|
||||||
@@ -455,6 +495,53 @@ export class EimsInvoiceRegistrationService {
|
|||||||
await this.alertStaff(invoiceId, status, lastError, deterministic);
|
await this.alertStaff(invoiceId, status, lastError, deterministic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoR accepted the document (a real IRN came back) but recording that locally failed — the
|
||||||
|
* reservation is still held from TX1, so the system-wide block goes on regardless of *why*
|
||||||
|
* `settleSuccess` failed. The IRN and ack date are written straight into `blockedReason` so a
|
||||||
|
* human resolving this never has to dig through logs for the one thing that must not be lost.
|
||||||
|
*/
|
||||||
|
private async blockOnKnownIrnPersistFailure(
|
||||||
|
invoiceId: string,
|
||||||
|
reservation: Reservation,
|
||||||
|
irn: string,
|
||||||
|
ackDate: string | undefined,
|
||||||
|
err: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
const reason =
|
||||||
|
`Invoice ${invoiceId} was ACCEPTED by EIMS (IRN ${irn}${ackDate ? `, ack ${ackDate}` : ""}) ` +
|
||||||
|
`but recording it locally failed: ${(err as Error)?.message ?? "unknown error"}. Resolve with ` +
|
||||||
|
`POST /invoices/${invoiceId}/eims/resolve using this IRN once the underlying issue is fixed — ` +
|
||||||
|
"do not resubmit, the document already exists at MoR.";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.dataSource.manager.update(EimsSystemState, reservation.stateId, { blockedReason: reason });
|
||||||
|
} catch (updateErr) {
|
||||||
|
// Even the block itself failed to write — last resort is the log, since there is nothing
|
||||||
|
// left to retry into.
|
||||||
|
this.logger.error(`Could not record EIMS block for invoice ${invoiceId}: ${reason}`, updateErr as Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(reason);
|
||||||
|
// Not alertStaff(): its canned copy for a non-deterministic result always says "the IRN is
|
||||||
|
// unknown", which is false here — the whole point of this path is that the IRN *is* known.
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.GENERIC,
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
title: "EIMS accepted an invoice but it was not recorded — all further filing is blocked",
|
||||||
|
body: reason,
|
||||||
|
link: `/dashboard/invoices/${invoiceId}`,
|
||||||
|
data: { invoiceId, eimsStatus: EimsInvoiceStatus.Unknown, irn, action: "EIMS_PERSIST_FAILED" },
|
||||||
|
});
|
||||||
|
} catch (notifyErr) {
|
||||||
|
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(notifyErr as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tell the people who can act about a failed filing.
|
* Tell the people who can act about a failed filing.
|
||||||
*
|
*
|
||||||
@@ -492,7 +579,9 @@ export class EimsInvoiceRegistrationService {
|
|||||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
|
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
|
||||||
private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> {
|
private async submit(
|
||||||
|
request: EimsInvoiceRequest,
|
||||||
|
): Promise<{ irn: string; ackDate?: string; signedQR?: string }> {
|
||||||
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
|
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
|
||||||
"/v1/register",
|
"/v1/register",
|
||||||
request,
|
request,
|
||||||
@@ -506,7 +595,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
response?.statusCode,
|
response?.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { irn, ackDate: response.body?.ackDate };
|
return { irn, ackDate: response.body?.ackDate, signedQR: response.body?.signedQR };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||||
@@ -572,17 +661,6 @@ export class EimsInvoiceRegistrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toView(invoice: Invoice): EimsInvoiceStatusView {
|
private toView(invoice: Invoice): EimsInvoiceStatusView {
|
||||||
const counter = invoice.eimsInvoiceCounter;
|
return toEimsInvoiceStatusView(invoice);
|
||||||
return {
|
|
||||||
invoiceId: invoice.id,
|
|
||||||
invoiceNumber: invoice.invoiceNumber,
|
|
||||||
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
|
||||||
eimsIrn: invoice.eimsIrn ?? null,
|
|
||||||
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
|
|
||||||
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
|
||||||
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
|
||||||
eimsAckDate: invoice.eimsAckDate ?? null,
|
|
||||||
eimsLastError: invoice.eimsLastError ?? null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Invoice` → `EimsInvoiceStatusView`, shared by every service that mutates EIMS state on an
|
||||||
|
* invoice (registration, cancellation, receipts) — one place defines what "the current EIMS
|
||||||
|
* status of an invoice" means to a caller.
|
||||||
|
*/
|
||||||
|
export function toEimsInvoiceStatusView(invoice: Invoice): EimsInvoiceStatusView {
|
||||||
|
const counter = invoice.eimsInvoiceCounter;
|
||||||
|
return {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
||||||
|
eimsIrn: invoice.eimsIrn ?? null,
|
||||||
|
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
|
||||||
|
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
||||||
|
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
||||||
|
eimsAckDate: invoice.eimsAckDate ?? null,
|
||||||
|
eimsLastError: invoice.eimsLastError ?? null,
|
||||||
|
eimsSignedQr: invoice.eimsSignedQr ?? null,
|
||||||
|
eimsCancelledAt: invoice.eimsCancelledAt ?? null,
|
||||||
|
eimsCancellationDate: invoice.eimsCancellationDate ?? null,
|
||||||
|
eimsCancellationReasonCode: invoice.eimsCancellationReasonCode ?? null,
|
||||||
|
eimsCancellationRemark: invoice.eimsCancellationRemark ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,8 +3,13 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
|||||||
|
|
||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||||
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
||||||
|
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual EIMS actions on an existing invoice.
|
* Manual EIMS actions on an existing invoice.
|
||||||
@@ -13,10 +18,12 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
|
|||||||
* normal production path — they exist for controlled testing and exceptional operations. Automatic
|
* normal production path — they exist for controlled testing and exceptional operations. Automatic
|
||||||
* submission after an invoice is issued is a separate phase; nothing here is called by it.
|
* submission after an invoice is issued is a separate phase; nothing here is called by it.
|
||||||
*
|
*
|
||||||
* `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned
|
* `eims_register`, `eims_resolve`, `eims_cancel` and `eims_receipt_register` are intentionally left
|
||||||
* to named admins instead. They are also separate permissions: resolving clears the system-wide
|
* out of every role preset and assigned to named admins instead. They are also separate
|
||||||
* chain block and can record an IRN against an invoice, which is a supervisor action, not an
|
* permissions: resolving clears the system-wide chain block and can record an IRN against an
|
||||||
* operational one. Only `eims/status` rides on the ordinary `invoices:view`.
|
* invoice, cancelling is its own irreversible-at-MoR action, and filing a receipt is a third —
|
||||||
|
* none follows from the right to register. Only `eims/status` and `eims/receipts` ride on the
|
||||||
|
* ordinary `invoices:view`.
|
||||||
*
|
*
|
||||||
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
|
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
|
||||||
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
|
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
|
||||||
@@ -27,7 +34,11 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller("invoices")
|
@Controller("invoices")
|
||||||
export class EimsInvoiceController {
|
export class EimsInvoiceController {
|
||||||
constructor(private readonly registration: EimsInvoiceRegistrationService) {}
|
constructor(
|
||||||
|
private readonly registration: EimsInvoiceRegistrationService,
|
||||||
|
private readonly cancellation: EimsCancellationService,
|
||||||
|
private readonly receipts: EimsReceiptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post(":id/eims/register")
|
@Post(":id/eims/register")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||||
@@ -65,4 +76,38 @@ export class EimsInvoiceController {
|
|||||||
status(@Param("id", ParseUUIDPipe) id: string) {
|
status(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.registration.getEimsStatus(id);
|
return this.registration.getEimsStatus(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/eims/cancel")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.",
|
||||||
|
})
|
||||||
|
cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) {
|
||||||
|
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/eims/receipt/sales")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
|
||||||
|
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })
|
||||||
|
registerSalesReceipt(@Param("id", ParseUUIDPipe) id: string, @Body() dto: RegisterSalesReceiptDto) {
|
||||||
|
return this.receipts.registerSalesReceipt(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/eims/receipt/withholding")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
|
||||||
|
@ApiOperation({ summary: "Register a withholding receipt with MoR EIMS against a registered invoice" })
|
||||||
|
registerWithholdingReceipt(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RegisterWithholdingReceiptDto,
|
||||||
|
) {
|
||||||
|
return this.receipts.registerWithholdingReceipt(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/eims/receipts")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.view)
|
||||||
|
@ApiOperation({ summary: "List every EIMS receipt filed against this invoice, newest first" })
|
||||||
|
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.receipts.listReceipts(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||||
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
|
|
||||||
|
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||||
|
const SESSION = { systemNumber: "B0360154BA", systemType: "SYS" };
|
||||||
|
|
||||||
|
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||||
|
({
|
||||||
|
id: INVOICE_ID,
|
||||||
|
invoiceNumber: "INV-20260807-00042",
|
||||||
|
companyId: "company-1",
|
||||||
|
currency: "ETB",
|
||||||
|
totalAmount: "10000.00",
|
||||||
|
paidAmount: "10000.00",
|
||||||
|
balanceAmount: "0.00",
|
||||||
|
eimsIrn: IRN,
|
||||||
|
...over,
|
||||||
|
}) as unknown as Invoice;
|
||||||
|
|
||||||
|
/** In-memory stand-in: Invoice lookups + EimsReceipt save/update/find. */
|
||||||
|
class FakeDb {
|
||||||
|
invoices = new Map<string, Invoice>();
|
||||||
|
receipts = new Map<string, Record<string, unknown>>();
|
||||||
|
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
constructor(invoices: Invoice[]) {
|
||||||
|
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private manager = {
|
||||||
|
findOne: async (entity: unknown, options: { where: { id: string } }) =>
|
||||||
|
entity === Invoice
|
||||||
|
? (this.invoices.get(options.where.id) ?? null)
|
||||||
|
: (this.receipts.get(options.where.id) ?? null),
|
||||||
|
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
|
||||||
|
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
|
||||||
|
save: async (_entity: unknown, data: Record<string, unknown>) => {
|
||||||
|
const id = `receipt-${++this.seq}`;
|
||||||
|
const row = { id, ...data };
|
||||||
|
this.receipts.set(id, row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
update: async (_entity: unknown, id: string, patch: Record<string, unknown>) => {
|
||||||
|
Object.assign(this.receipts.get(id)!, patch);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
asDataSource(): DataSource {
|
||||||
|
return {
|
||||||
|
manager: this.manager,
|
||||||
|
query: async () => (this.companyContact ? [this.companyContact] : []),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = (): EimsConfig => ({ tin: "0053481357" }) as EimsConfig;
|
||||||
|
|
||||||
|
const build = (
|
||||||
|
db: FakeDb,
|
||||||
|
postBearer: jest.Mock,
|
||||||
|
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||||
|
) =>
|
||||||
|
new EimsReceiptService(
|
||||||
|
db.asDataSource(),
|
||||||
|
{ get: () => config() } as unknown as ConfigService,
|
||||||
|
{ postBearer } as unknown as EimsClientService,
|
||||||
|
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
||||||
|
{ directSend } as unknown as NotificationsService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const okResponse = (over: Record<string, unknown> = {}) => ({
|
||||||
|
statusCode: 200,
|
||||||
|
message: "Success",
|
||||||
|
body: { status: "A", rrn: "rrn-value", qr: "iVBORw0KGgo...", ...over },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsReceiptService.registerSalesReceipt", () => {
|
||||||
|
it("registers a sales receipt and persists the RRN/QR", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const receipt = await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {
|
||||||
|
modeOfPayment: "CASH",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(postBearer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/sales");
|
||||||
|
const request = postBearer.mock.calls[0][1];
|
||||||
|
expect(request.SellerTIN).toBe("0053481357");
|
||||||
|
expect(request.SourceSystemNumber).toBe("B0360154BA");
|
||||||
|
expect(request.Invoices[0].InvoiceIRN).toBe(IRN);
|
||||||
|
expect(request.TransactionDetails.ModeOfPayment).toBe("CASH");
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
|
||||||
|
expect(receipt.rrn).toBe("rrn-value");
|
||||||
|
expect(receipt.qr).toBe("iVBORw0KGgo...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
await build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
|
||||||
|
|
||||||
|
expect(postBearer.mock.calls[0][1].Invoices[0].PaymentCoverage).toBe("PARTIAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unconfirmed receipt currency locally, with zero HTTP calls", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerSalesReceipt(INVOICE_ID, {
|
||||||
|
modeOfPayment: "CASH",
|
||||||
|
currency: "GBP",
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to file a receipt against an unregistered invoice", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsIrn: null })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
|
||||||
|
).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
const [receipt] = [...db.receipts.values()];
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Failed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
|
||||||
|
).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
const [receipt] = [...db.receipts.values()];
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the buyer company on success, without blocking the result", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
|
||||||
|
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const receipt = await build(db, postBearer, directSend).registerSalesReceipt(INVOICE_ID, {
|
||||||
|
modeOfPayment: "CASH",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
|
||||||
|
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("sales"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsReceiptService.registerWithholdingReceipt", () => {
|
||||||
|
it("registers a withholding receipt and persists the RRN", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
|
||||||
|
const receipt = await build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, {
|
||||||
|
type: "TWHT",
|
||||||
|
preTaxAmount: 6000,
|
||||||
|
withholdingAmount: 120,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/withholding");
|
||||||
|
const request = postBearer.mock.calls[0][1];
|
||||||
|
expect(request.InvoiceDetail.InvoiceIRN).toBe(IRN);
|
||||||
|
expect(request.WithholdDetail).toMatchObject({ Type: "TWHT", PreTaxAmount: 6000, WithholdingAmount: 120 });
|
||||||
|
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
|
||||||
|
expect(receipt.rrn).toBe("rrn-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an exchangeRate for a non-ETB invoice", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ currency: "USD" })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, {
|
||||||
|
type: "TWHT",
|
||||||
|
preTaxAmount: 6000,
|
||||||
|
withholdingAmount: 120,
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsReceiptService.listReceipts", () => {
|
||||||
|
it("returns every receipt filed against the invoice", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||||
|
const service = build(db, postBearer);
|
||||||
|
|
||||||
|
await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
|
||||||
|
await service.registerWithholdingReceipt(INVOICE_ID, {
|
||||||
|
type: "TWHT",
|
||||||
|
preTaxAmount: 100,
|
||||||
|
withholdingAmount: 2,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const list = await service.listReceipts(INVOICE_ID);
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
259
apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts
Normal file
259
apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
|
import { EimsClientService } from "./eims-client.service";
|
||||||
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||||
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
|
import {
|
||||||
|
EimsReceiptResponse,
|
||||||
|
EimsSalesReceiptRequest,
|
||||||
|
EimsWithholdReceiptRequest,
|
||||||
|
} from "./eims-receipt.types";
|
||||||
|
import { EimsInvoiceError } from "./eims-registration.types";
|
||||||
|
|
||||||
|
/** `ReceiptCurrency` / withholding `InvoiceDetail.Currency` — confirmed by a live rule error. */
|
||||||
|
const EIMS_RECEIPT_CURRENCIES = ["ETB", "USD", "CAD"] as const;
|
||||||
|
|
||||||
|
/** Failure kinds where MoR gave a complete answer — the receipt definitively did not register. */
|
||||||
|
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/receipt/sales` and `POST /v1/receipt/withholding`, both against an already-registered
|
||||||
|
* invoice.
|
||||||
|
*
|
||||||
|
* No counter/reservation machinery: the collection shows no MoR-enforced ordering on
|
||||||
|
* `ReceiptCounter` the way `InvoiceCounter` has a documented "expected: N" rule, so — unlike
|
||||||
|
* registration — there is no shared sequence to protect. Each attempt is its own `EimsReceipt` row:
|
||||||
|
* created before the HTTP call (so a crash mid-flight leaves an UNKNOWN row instead of nothing),
|
||||||
|
* settled after it. An ambiguous outcome is never auto-retried — receipts have no evidenced
|
||||||
|
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
|
||||||
|
* applies as an unacknowledged registration: a human must check the MoR portal first.
|
||||||
|
*
|
||||||
|
* Several request fields have no confirmed source in this codebase (payment method, collector,
|
||||||
|
* withholding rate/amount) and are never guessed — see the two DTOs.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsReceiptService {
|
||||||
|
private readonly logger = new Logger(EimsReceiptService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly client: EimsClientService,
|
||||||
|
private readonly auth: EimsAuthService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get cfg(): EimsConfig {
|
||||||
|
return this.config.get<EimsConfig>("eims")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerSalesReceipt(invoiceId: string, dto: RegisterSalesReceiptDto): Promise<EimsReceipt> {
|
||||||
|
const invoice = await this.loadRegisteredInvoice(invoiceId);
|
||||||
|
const currency = dto.currency ?? invoice.currency;
|
||||||
|
this.assertReceiptCurrency(currency);
|
||||||
|
|
||||||
|
const session = await this.auth.getSessionContext();
|
||||||
|
const receiptNumber = this.generateReceiptNumber(invoice);
|
||||||
|
const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount);
|
||||||
|
const balance = Number(invoice.balanceAmount);
|
||||||
|
|
||||||
|
const request: EimsSalesReceiptRequest = {
|
||||||
|
ReceiptNumber: receiptNumber,
|
||||||
|
ReceiptType: "Sales Receipts",
|
||||||
|
Reason: dto.reason ?? "Payment received",
|
||||||
|
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
|
||||||
|
// error for this field was ever observed to confirm which form MoR actually requires.
|
||||||
|
ReceiptDate: new Date().toISOString(),
|
||||||
|
ReceiptCounter: String(Date.now()),
|
||||||
|
ManualReceiptNumber: receiptNumber,
|
||||||
|
SourceSystemType: session.systemType,
|
||||||
|
SourceSystemNumber: session.systemNumber,
|
||||||
|
ReceiptCurrency: currency,
|
||||||
|
ExchangeRate: dto.exchangeRate ?? null,
|
||||||
|
CollectedAmount: collectedAmount,
|
||||||
|
SellerTIN: this.cfg.tin,
|
||||||
|
Invoices: [
|
||||||
|
{
|
||||||
|
InvoiceIRN: invoice.eimsIrn!,
|
||||||
|
PaymentCoverage: dto.paymentCoverage ?? (balance <= 0 ? "FULL" : "PARTIAL"),
|
||||||
|
InvoicePaidAmount: collectedAmount,
|
||||||
|
DiscountAmount: null,
|
||||||
|
RemainingAmount: balance,
|
||||||
|
TotalAmount: Number(invoice.totalAmount),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
TransactionDetails: {
|
||||||
|
ModeOfPayment: dto.modeOfPayment,
|
||||||
|
ChequeNumber: dto.chequeNumber ?? null,
|
||||||
|
CPONumber: dto.cpoNumber ?? null,
|
||||||
|
DocumentNumber: dto.documentNumber ?? null,
|
||||||
|
CollectorName: dto.collectorName ?? null,
|
||||||
|
PaymentServiceProvider: dto.paymentServiceProvider ?? null,
|
||||||
|
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
|
||||||
|
AccountNumber: dto.accountNumber ?? null,
|
||||||
|
TransactionNumber: dto.transactionNumber ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.submit(invoice, "SALES", receiptNumber, request, "/v1/receipt/sales");
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerWithholdingReceipt(
|
||||||
|
invoiceId: string,
|
||||||
|
dto: RegisterWithholdingReceiptDto,
|
||||||
|
): Promise<EimsReceipt> {
|
||||||
|
const invoice = await this.loadRegisteredInvoice(invoiceId);
|
||||||
|
if (invoice.currency !== "ETB" && dto.exchangeRate == null) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_EXCHANGE_RATE_REQUIRED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.auth.getSessionContext();
|
||||||
|
const receiptNumber = this.generateReceiptNumber(invoice);
|
||||||
|
|
||||||
|
const request: EimsWithholdReceiptRequest = {
|
||||||
|
ReceiptNumber: receiptNumber,
|
||||||
|
Reason: dto.reason ?? "Withholding",
|
||||||
|
ReceiptCounter: String(Date.now()),
|
||||||
|
ManualReceiptNumber: receiptNumber,
|
||||||
|
SourceSystemType: session.systemType,
|
||||||
|
SourceSystemNumber: session.systemNumber,
|
||||||
|
InvoiceDetail: {
|
||||||
|
InvoiceIRN: invoice.eimsIrn!,
|
||||||
|
Currency: invoice.currency,
|
||||||
|
ExchangeRate: dto.exchangeRate ?? null,
|
||||||
|
},
|
||||||
|
WithholdDetail: {
|
||||||
|
Type: dto.type,
|
||||||
|
Rate: dto.rate ?? null,
|
||||||
|
PreTaxAmount: dto.preTaxAmount,
|
||||||
|
WithholdingAmount: dto.withholdingAmount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.submit(invoice, "WITHHOLDING", receiptNumber, request, "/v1/receipt/withholding");
|
||||||
|
}
|
||||||
|
|
||||||
|
async listReceipts(invoiceId: string): Promise<EimsReceipt[]> {
|
||||||
|
return this.dataSource.manager.find(EimsReceipt, {
|
||||||
|
where: { invoiceId },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async submit(
|
||||||
|
invoice: Invoice,
|
||||||
|
kind: EimsReceiptKind,
|
||||||
|
receiptNumber: string,
|
||||||
|
request: EimsSalesReceiptRequest | EimsWithholdReceiptRequest,
|
||||||
|
path: "/v1/receipt/sales" | "/v1/receipt/withholding",
|
||||||
|
): Promise<EimsReceipt> {
|
||||||
|
// Committed before the HTTP call — a crash mid-flight leaves an UNKNOWN row, not nothing.
|
||||||
|
const receipt = await this.dataSource.manager.save(EimsReceipt, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
kind,
|
||||||
|
status: EimsReceiptStatus.Submitting,
|
||||||
|
receiptNumber,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
request: request as unknown as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.postBearer<
|
||||||
|
EimsSalesReceiptRequest | EimsWithholdReceiptRequest,
|
||||||
|
EimsReceiptResponse
|
||||||
|
>(path, request);
|
||||||
|
const rrn = response?.body?.rrn;
|
||||||
|
if (!rrn) {
|
||||||
|
throw new EimsApiException(
|
||||||
|
"SCHEMA_VALIDATION",
|
||||||
|
"EIMS receipt registration returned no rrn",
|
||||||
|
response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.dataSource.manager.update(EimsReceipt, receipt.id, {
|
||||||
|
status: EimsReceiptStatus.Registered,
|
||||||
|
rrn,
|
||||||
|
qr: response.body?.qr ?? null,
|
||||||
|
ackStatus: response.body?.status ?? null,
|
||||||
|
});
|
||||||
|
this.logger.log(`${kind} receipt ${receiptNumber} registered for invoice ${invoice.invoiceNumber} (RRN ${rrn})`);
|
||||||
|
await this.notifyBuyer(invoice, kind, receiptNumber);
|
||||||
|
} catch (err) {
|
||||||
|
const api = err instanceof EimsApiException ? err : null;
|
||||||
|
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
|
||||||
|
const lastError: EimsInvoiceError = {
|
||||||
|
kind: api?.kind ?? "UNKNOWN",
|
||||||
|
message: (err as Error)?.message ?? "unknown error",
|
||||||
|
httpStatus: api?.httpStatus,
|
||||||
|
details: api?.details,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.dataSource.manager.update(EimsReceipt, receipt.id, {
|
||||||
|
status: deterministic ? EimsReceiptStatus.Failed : EimsReceiptStatus.Unknown,
|
||||||
|
lastError,
|
||||||
|
} as QueryDeepPartialEntity<EimsReceipt>);
|
||||||
|
this.logger.error(
|
||||||
|
`${kind} receipt ${receiptNumber} for invoice ${invoice.invoiceNumber} ${deterministic ? "FAILED" : "UNKNOWN"}: ${lastError.message}`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await this.dataSource.manager.findOne(EimsReceipt, { where: { id: receipt.id } });
|
||||||
|
return saved!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyBuyer(invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sendCompanyChannels(
|
||||||
|
this.dataSource,
|
||||||
|
this.notifications,
|
||||||
|
invoice.companyId,
|
||||||
|
`A ${kind === "SALES" ? "sales" : "withholding"} receipt (${receiptNumber}) has been registered ` +
|
||||||
|
`with MoR EIMS for invoice ${invoice.invoiceNumber}.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`EIMS receipt buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadRegisteredInvoice(invoiceId: string): Promise<Invoice> {
|
||||||
|
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
if (!invoice.eimsIrn) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_NOT_REGISTERED",
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — no IRN to file a receipt against.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertReceiptCurrency(currency: string): void {
|
||||||
|
if (!EIMS_RECEIPT_CURRENCIES.includes(currency as (typeof EIMS_RECEIPT_CURRENCIES)[number])) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: "EIMS_RECEIPT_CURRENCY_INVALID",
|
||||||
|
message: `EIMS receipt currency must be one of ${EIMS_RECEIPT_CURRENCIES.join(", ")}, got "${currency}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateReceiptNumber(invoice: Invoice): string {
|
||||||
|
return `REC-${invoice.invoiceNumber}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts
Normal file
97
apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Wire types for `POST /v1/receipt/sales` and `POST /v1/receipt/withholding`, taken verbatim from
|
||||||
|
* the Postman collection's saved requests/responses. Bearer-authenticated but unsigned — no
|
||||||
|
* `{request,signature,certificate}` envelope in the collection's saved bodies, same as `/v1/verify`
|
||||||
|
* and `/v1/cancel`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One entry of `Invoices[]` on a sales receipt. */
|
||||||
|
export interface EimsSalesReceiptInvoice {
|
||||||
|
InvoiceIRN: string;
|
||||||
|
PaymentCoverage: string;
|
||||||
|
InvoicePaidAmount: number;
|
||||||
|
DiscountAmount: number | null;
|
||||||
|
RemainingAmount: number | null;
|
||||||
|
TotalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ModeOfPayment` enum confirmed by a live schema error: exactly these eight values, verbatim
|
||||||
|
* spelling and casing (including the two multi-word ones).
|
||||||
|
*/
|
||||||
|
export const EIMS_MODE_OF_PAYMENT = [
|
||||||
|
"CASH",
|
||||||
|
"CHEQUE",
|
||||||
|
"CPO",
|
||||||
|
"Local Bank Transfer",
|
||||||
|
"SWIFT",
|
||||||
|
"Wire Transfer",
|
||||||
|
"Letter of Credit",
|
||||||
|
"Card",
|
||||||
|
] as const;
|
||||||
|
export type EimsModeOfPayment = (typeof EIMS_MODE_OF_PAYMENT)[number];
|
||||||
|
|
||||||
|
export interface EimsSalesReceiptTransactionDetails {
|
||||||
|
ModeOfPayment: EimsModeOfPayment;
|
||||||
|
ChequeNumber: string | null;
|
||||||
|
CPONumber: string | null;
|
||||||
|
DocumentNumber: string | null;
|
||||||
|
CollectorName: string | null;
|
||||||
|
PaymentServiceProvider: string | null;
|
||||||
|
OtherPaymentServiceProviderName: string | null;
|
||||||
|
AccountNumber: string | null;
|
||||||
|
TransactionNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsSalesReceiptRequest {
|
||||||
|
ReceiptNumber: string;
|
||||||
|
ReceiptType: string;
|
||||||
|
Reason: string;
|
||||||
|
ReceiptDate: string;
|
||||||
|
ReceiptCounter: string;
|
||||||
|
ManualReceiptNumber: string;
|
||||||
|
SourceSystemType: string;
|
||||||
|
SourceSystemNumber: string;
|
||||||
|
/** Confirmed enum via a live rule error: "Currency should be ETB, USD, CAD". */
|
||||||
|
ReceiptCurrency: string;
|
||||||
|
ExchangeRate: number | null;
|
||||||
|
CollectedAmount: number;
|
||||||
|
SellerTIN: string;
|
||||||
|
Invoices: EimsSalesReceiptInvoice[];
|
||||||
|
TransactionDetails: EimsSalesReceiptTransactionDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsWithholdReceiptRequest {
|
||||||
|
ReceiptNumber: string;
|
||||||
|
Reason: string;
|
||||||
|
ReceiptCounter: string;
|
||||||
|
ManualReceiptNumber: string;
|
||||||
|
SourceSystemType: string;
|
||||||
|
SourceSystemNumber: string;
|
||||||
|
InvoiceDetail: {
|
||||||
|
InvoiceIRN: string;
|
||||||
|
Currency: string;
|
||||||
|
ExchangeRate: number | null;
|
||||||
|
};
|
||||||
|
WithholdDetail: {
|
||||||
|
/** Only "TWHT" has ever been observed; not restricted to it, since no fuller enum is confirmed. */
|
||||||
|
Type: string;
|
||||||
|
Rate: number | null;
|
||||||
|
PreTaxAmount: number;
|
||||||
|
WithholdingAmount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success body — identical shape for both receipt endpoints. */
|
||||||
|
export interface EimsReceiptResponseBody {
|
||||||
|
status: string;
|
||||||
|
rrn: string;
|
||||||
|
/** Base64 PNG, same convention as the register response's `signedQR`. */
|
||||||
|
qr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsReceiptResponse {
|
||||||
|
statusCode?: number;
|
||||||
|
message?: string;
|
||||||
|
body?: EimsReceiptResponseBody;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export enum EimsInvoiceStatus {
|
|||||||
Registered = "REGISTERED",
|
Registered = "REGISTERED",
|
||||||
Failed = "FAILED",
|
Failed = "FAILED",
|
||||||
Unknown = "UNKNOWN",
|
Unknown = "UNKNOWN",
|
||||||
|
/** Successfully cancelled at MoR via `POST /v1/cancel`. Terminal — never re-registered. */
|
||||||
|
Cancelled = "CANCELLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
|
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
|
||||||
@@ -65,6 +67,28 @@ export interface EimsVerifyResponse {
|
|||||||
body?: EimsVerifyResponseBody;
|
body?: EimsVerifyResponseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/cancel` — bearer-authenticated but unsigned, same shape as `/v1/verify` (no
|
||||||
|
* `{request,signature,certificate}` envelope in the collection's saved request).
|
||||||
|
*/
|
||||||
|
export interface EimsCancelRequest {
|
||||||
|
Irn: string;
|
||||||
|
/** Numeric code as a string, e.g. "1" (Duplicate), "6" (Calculation Error) per the collection docs. */
|
||||||
|
ReasonCode: string;
|
||||||
|
Remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success body: `{ cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" }` — a Java Date#toString(), not ISO. Stored verbatim, like `eimsAckDate`. */
|
||||||
|
export interface EimsCancelResponseBody {
|
||||||
|
cancellationDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsCancelResponse {
|
||||||
|
statusCode?: number;
|
||||||
|
message?: string;
|
||||||
|
body?: EimsCancelResponseBody;
|
||||||
|
}
|
||||||
|
|
||||||
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
||||||
export interface EimsInvoiceError {
|
export interface EimsInvoiceError {
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -86,4 +110,10 @@ export interface EimsInvoiceStatusView {
|
|||||||
eimsSubmittedAt: Date | null;
|
eimsSubmittedAt: Date | null;
|
||||||
eimsAckDate: string | null;
|
eimsAckDate: string | null;
|
||||||
eimsLastError: EimsInvoiceError | null;
|
eimsLastError: EimsInvoiceError | null;
|
||||||
|
/** Raw base64 PNG from MoR, already rendered on their side — see `Invoice.eimsSignedQr`. */
|
||||||
|
eimsSignedQr: string | null;
|
||||||
|
eimsCancelledAt: Date | null;
|
||||||
|
eimsCancellationDate: string | null;
|
||||||
|
eimsCancellationReasonCode: string | null;
|
||||||
|
eimsCancellationRemark: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,14 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
|||||||
buyerCountryCode: null,
|
buyerCountryCode: null,
|
||||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||||
|
taxCodeByChargeType: {},
|
||||||
|
taxRateByChargeType: {},
|
||||||
|
exciseByChargeType: {},
|
||||||
|
discountByChargeType: {},
|
||||||
cashierName: null,
|
cashierName: null,
|
||||||
salesPersonName: null,
|
salesPersonName: null,
|
||||||
|
buyerIdType: null,
|
||||||
|
buyerIdNumber: null,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
|
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||||
|
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||||
import { EimsInvoiceController } from "./eims-invoice.controller";
|
import { EimsInvoiceController } from "./eims-invoice.controller";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
import { EimsSignerService } from "./eims-signer.service";
|
import { EimsSignerService } from "./eims-signer.service";
|
||||||
|
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,8 +26,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
||||||
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
|
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [EimsInvoiceController],
|
controllers: [EimsInvoiceController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -33,7 +38,15 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
EimsClientService,
|
EimsClientService,
|
||||||
EimsInvoiceRegistrationService,
|
EimsInvoiceRegistrationService,
|
||||||
EimsAutoSubmitService,
|
EimsAutoSubmitService,
|
||||||
|
EimsCancellationService,
|
||||||
|
EimsReceiptService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
EimsAuthService,
|
||||||
|
EimsClientService,
|
||||||
|
EimsInvoiceRegistrationService,
|
||||||
|
EimsCancellationService,
|
||||||
|
EimsReceiptService,
|
||||||
],
|
],
|
||||||
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
|
|
||||||
})
|
})
|
||||||
export class EimsModule {}
|
export class EimsModule {}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "../../billing/entities/invoice.entity";
|
||||||
|
import { EimsInvoiceError } from "../eims-registration.types";
|
||||||
|
|
||||||
|
export type EimsReceiptKind = "SALES" | "WITHHOLDING";
|
||||||
|
|
||||||
|
/** Same shape as `EimsInvoiceStatus`, for a receipt instead of an invoice. */
|
||||||
|
export enum EimsReceiptStatus {
|
||||||
|
NotSubmitted = "NOT_SUBMITTED",
|
||||||
|
Submitting = "SUBMITTING",
|
||||||
|
Registered = "REGISTERED",
|
||||||
|
Failed = "FAILED",
|
||||||
|
Unknown = "UNKNOWN",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One `POST /v1/receipt/sales` or `POST /v1/receipt/withholding` attempt, linked to the invoice it
|
||||||
|
* was filed against. An invoice can have more than one receipt (partial payments), so this is its
|
||||||
|
* own table rather than columns on `Invoice`.
|
||||||
|
*
|
||||||
|
* No counter/sequence reservation the way invoice registration has: the collection's `/v1/receipt/*`
|
||||||
|
* examples show no MoR-enforced ordering rule on `ReceiptCounter` (unlike `InvoiceCounter`'s
|
||||||
|
* documented "expected: N"), so this is a plain row per attempt.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "eims_receipts" })
|
||||||
|
@Index(["invoiceId"])
|
||||||
|
export class EimsReceipt extends BaseEntity {
|
||||||
|
@Column({ name: "invoice_id", type: "uuid" })
|
||||||
|
invoiceId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Invoice)
|
||||||
|
@JoinColumn({ name: "invoice_id" })
|
||||||
|
invoice?: Invoice;
|
||||||
|
|
||||||
|
@Column({ name: "kind", type: "varchar", length: 16 })
|
||||||
|
kind!: EimsReceiptKind;
|
||||||
|
|
||||||
|
@Column({ name: "status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
||||||
|
status!: EimsReceiptStatus;
|
||||||
|
|
||||||
|
/** Our own generated `ReceiptNumber` / `ManualReceiptNumber` (both sent equal — see the service). */
|
||||||
|
@Column({ name: "receipt_number", type: "varchar", length: 64 })
|
||||||
|
receiptNumber!: string;
|
||||||
|
|
||||||
|
/** MoR's Receipt Reference Number, returned only on success. */
|
||||||
|
@Column({ name: "rrn", type: "text", nullable: true })
|
||||||
|
rrn?: string | null;
|
||||||
|
|
||||||
|
/** Base64 PNG from MoR, same convention as `Invoice.eimsSignedQr` — embed directly. */
|
||||||
|
@Column({ name: "qr", type: "text", nullable: true })
|
||||||
|
qr?: string | null;
|
||||||
|
|
||||||
|
/** MoR's own `"A"` (active) / `"F"` (failed) status marker, stored verbatim. */
|
||||||
|
@Column({ name: "ack_status", type: "varchar", length: 8, nullable: true })
|
||||||
|
ackStatus?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "submitted_at", type: "timestamptz", nullable: true })
|
||||||
|
submittedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "last_error", type: "jsonb", nullable: true })
|
||||||
|
lastError?: EimsInvoiceError | null;
|
||||||
|
|
||||||
|
/** The exact request body sent — receipts are bearer-only/unsigned, so nothing secret is in it. */
|
||||||
|
@Column({ name: "request", type: "jsonb", nullable: true })
|
||||||
|
request?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
@@ -30,8 +30,11 @@ export class EimsSystemState extends BaseEntity {
|
|||||||
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
|
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
|
||||||
inFlightDocumentNumber?: number | null;
|
inFlightDocumentNumber?: number | null;
|
||||||
|
|
||||||
/** IRN of the last successful registration; null until the first one succeeds. */
|
/**
|
||||||
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
|
* IRN of the last successful registration; null until the first one succeeds. `text`, not a
|
||||||
|
* fixed varchar — same reasoning as `Invoice.eimsIrn`: a real IRN already overflowed varchar(64).
|
||||||
|
*/
|
||||||
|
@Column({ name: "previous_irn", type: "text", nullable: true })
|
||||||
previousIrn?: string | null;
|
previousIrn?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ import {
|
|||||||
perEdgeConsistUsage,
|
perEdgeConsistUsage,
|
||||||
validateContainerPlacements,
|
validateContainerPlacements,
|
||||||
validateMixedTrainLimitsPerEdge,
|
validateMixedTrainLimitsPerEdge,
|
||||||
|
MAX_TEU_SLOTS_PER_WAGON,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from '../utils/wagon-plan.util';
|
} from '../utils/wagon-plan.util';
|
||||||
@@ -2930,6 +2931,10 @@ export class TrainSchedulingService {
|
|||||||
// dispatch pre-check keeps reporting these bookings as unloaded).
|
// dispatch pre-check keeps reporting these bookings as unloaded).
|
||||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||||
if (wagonAssignedIds.size) {
|
if (wagonAssignedIds.size) {
|
||||||
|
// Re-check the 1×40ft/2×20ft-per-wagon packing rule right before loading
|
||||||
|
// is confirmed — allocation time already enforces it, but a wagon swap or
|
||||||
|
// an edited allocation since then could have broken it unnoticed.
|
||||||
|
await this.assertWagonContainerCapacity(scheduleId);
|
||||||
// Export cargo must be received at the warehouse with a GRN before it can
|
// Export cargo must be received at the warehouse with a GRN before it can
|
||||||
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
||||||
if (this.isExportSchedule(schedule)) {
|
if (this.isExportSchedule(schedule)) {
|
||||||
@@ -3397,6 +3402,72 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-check the 1×40ft / 2×20ft-per-wagon packing rule at loading confirmation
|
||||||
|
* time. `validateContainerPlacements` already enforces this the moment a
|
||||||
|
* booking is allocated to a wagon, but nothing re-checks it afterwards — a
|
||||||
|
* wagon swap, an edited allocation, or a container item added out-of-band
|
||||||
|
* between allocation and loading could still leave a wagon over its 2-TEU
|
||||||
|
* capacity undetected until the train is already loaded. This closes that gap
|
||||||
|
* by summing the TEU actually persisted per wagon (40ft = 2 TEU, 20ft = 1 TEU,
|
||||||
|
* same size resolution as the marshalling document) right before loading is
|
||||||
|
* confirmed.
|
||||||
|
*/
|
||||||
|
private async assertWagonContainerCapacity(scheduleId: string): Promise<void> {
|
||||||
|
const rows: Array<{ sequenceNo: number; wagonNumber: string | null; teuUsed: string }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||||
|
pw.wagon_number AS "wagonNumber",
|
||||||
|
SUM(
|
||||||
|
CASE COALESCE(ct.size_ft, bct.size_ft)
|
||||||
|
WHEN 40 THEN 2
|
||||||
|
WHEN 20 THEN 1
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN bc.container_size ILIKE '%40%' THEN 2
|
||||||
|
WHEN bc.container_size ILIKE '%20%' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
END
|
||||||
|
) AS "teuUsed"
|
||||||
|
FROM freight.train_set_wagons tsw
|
||||||
|
JOIN freight.wagon_booking_allocations wba
|
||||||
|
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
|
||||||
|
JOIN freight.wagon_allocation_container_items wci
|
||||||
|
ON wci.wagon_booking_allocation_id = wba.id AND wci.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.container_types ct ON ct.id = wci.container_type_id
|
||||||
|
LEFT JOIN freight.booking_container bc ON bc.id = wci.booking_container_id
|
||||||
|
LEFT JOIN freight.container_types bct ON bct.id = bc.container_type_id
|
||||||
|
LEFT JOIN freight.wagons pw ON pw.id = tsw.physical_wagon_id
|
||||||
|
WHERE tsw.train_set_id = (
|
||||||
|
SELECT train_set_id FROM freight.train_schedules WHERE id = $1
|
||||||
|
)
|
||||||
|
AND tsw.deleted_at IS NULL
|
||||||
|
GROUP BY tsw.id, tsw.sequence_no, pw.wagon_number
|
||||||
|
HAVING SUM(
|
||||||
|
CASE COALESCE(ct.size_ft, bct.size_ft)
|
||||||
|
WHEN 40 THEN 2
|
||||||
|
WHEN 20 THEN 1
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN bc.container_size ILIKE '%40%' THEN 2
|
||||||
|
WHEN bc.container_size ILIKE '%20%' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
END
|
||||||
|
) > $2`,
|
||||||
|
[scheduleId, MAX_TEU_SLOTS_PER_WAGON],
|
||||||
|
);
|
||||||
|
if (rows.length) {
|
||||||
|
const labels = rows
|
||||||
|
.map((r) => `wagon #${r.sequenceNo}${r.wagonNumber ? ` (${r.wagonNumber})` : ''}`)
|
||||||
|
.join(', ');
|
||||||
|
throw new BadRequestException(
|
||||||
|
`These wagons exceed capacity (max 1×40ft or 2×20ft per wagon) — fix the container placement before confirming loading: ${labels}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
String(value ?? '-')
|
String(value ?? '-')
|
||||||
|
|||||||
@@ -482,6 +482,20 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:invoices:eims_resolve",
|
"edr_freight_app:invoices:eims_resolve",
|
||||||
"Resolve a blocked MoR EIMS submission",
|
"Resolve a blocked MoR EIMS submission",
|
||||||
),
|
),
|
||||||
|
// Cancellation is a separate irreversible-at-MoR action from registration — its own grant,
|
||||||
|
// same reasoning as eims_register.
|
||||||
|
perm(
|
||||||
|
"d2b00001-0001-4000-8000-000000000008",
|
||||||
|
"edr_freight_app:invoices:eims_cancel",
|
||||||
|
"Cancel a registered invoice with MoR EIMS",
|
||||||
|
),
|
||||||
|
// Covers both sales and withholding receipts — same risk profile (filing a document with
|
||||||
|
// MoR), no reason to split further.
|
||||||
|
perm(
|
||||||
|
"d2b00001-0001-4000-8000-000000000009",
|
||||||
|
"edr_freight_app:invoices:eims_receipt_register",
|
||||||
|
"Register a sales or withholding receipt with MoR EIMS",
|
||||||
|
),
|
||||||
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
||||||
// the invoice. Moves money state, so it is its own grant, not part of view.
|
// the invoice. Moves money state, so it is its own grant, not part of view.
|
||||||
perm(
|
perm(
|
||||||
@@ -1707,6 +1721,8 @@ export const FREIGHT_PERMS = {
|
|||||||
export: "edr_freight_app:invoices:export",
|
export: "edr_freight_app:invoices:export",
|
||||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||||
|
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||||
|
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||||
},
|
},
|
||||||
firstMile: {
|
firstMile: {
|
||||||
@@ -2225,10 +2241,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
FREIGHT_PERMS.invoices.view,
|
FREIGHT_PERMS.invoices.view,
|
||||||
FREIGHT_PERMS.invoices.export,
|
FREIGHT_PERMS.invoices.export,
|
||||||
// Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve.
|
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
|
||||||
// Invoices are filed with MoR by the workflow, not by a person, so filing is not a
|
// eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so
|
||||||
// Finance job function — the endpoints exist for controlled testing and exceptional
|
// filing is not a Finance job function — the endpoints exist for controlled testing and
|
||||||
// operations, and are assigned to named admins rather than a role preset.
|
// exceptional operations, and are assigned to named admins rather than a role preset.
|
||||||
FREIGHT_PERMS.payments.view,
|
FREIGHT_PERMS.payments.view,
|
||||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export type EimsInvoiceStatus =
|
|||||||
| "SUBMITTING"
|
| "SUBMITTING"
|
||||||
| "REGISTERED"
|
| "REGISTERED"
|
||||||
| "FAILED"
|
| "FAILED"
|
||||||
| "UNKNOWN";
|
| "UNKNOWN"
|
||||||
|
| "CANCELLED";
|
||||||
|
|
||||||
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
|
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
|
||||||
export interface EimsInvoiceError {
|
export interface EimsInvoiceError {
|
||||||
@@ -30,6 +31,28 @@ export interface EimsInvoiceStatusView {
|
|||||||
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
|
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
|
||||||
eimsAckDate: string | null;
|
eimsAckDate: string | null;
|
||||||
eimsLastError: EimsInvoiceError | null;
|
eimsLastError: EimsInvoiceError | null;
|
||||||
|
/** Base64 PNG from MoR, already rendered on their side — embed as `data:image/png;base64,...`. */
|
||||||
|
eimsSignedQr: string | null;
|
||||||
|
eimsCancelledAt: string | null;
|
||||||
|
/** MoR's own confirmation string (a Java `Date#toString()`), stored verbatim — display as-is. */
|
||||||
|
eimsCancellationDate: string | null;
|
||||||
|
eimsCancellationReasonCode: string | null;
|
||||||
|
eimsCancellationRemark: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `EimsReceipt` row — mirrors `entities/eims-receipt.entity.ts`. */
|
||||||
|
export interface EimsReceiptView {
|
||||||
|
id: string;
|
||||||
|
invoiceId: string;
|
||||||
|
kind: "SALES" | "WITHHOLDING";
|
||||||
|
status: EimsInvoiceStatus;
|
||||||
|
receiptNumber: string;
|
||||||
|
rrn: string | null;
|
||||||
|
/** Base64 PNG, same convention as `eimsSignedQr`. */
|
||||||
|
qr: string | null;
|
||||||
|
ackStatus: string | null;
|
||||||
|
submittedAt: string | null;
|
||||||
|
lastError: EimsInvoiceError | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `POST /v1/verify` response, echoed back from the gateway. */
|
/** `POST /v1/verify` response, echoed back from the gateway. */
|
||||||
|
|||||||
167
pnpm-lock.yaml
generated
167
pnpm-lock.yaml
generated
@@ -237,16 +237,16 @@ importers:
|
|||||||
version: 18.0.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 18.0.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76)
|
version: 5.4.0(react-hook-form@7.77.0(react@19.2.6))
|
||||||
'@mantine/core':
|
'@mantine/core':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/dates':
|
'@mantine/dates':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.2(react@19.2.6)
|
version: 9.3.0(react@19.2.6)
|
||||||
'@mdxeditor/editor':
|
'@mdxeditor/editor':
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32)
|
version: 4.2.0(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32)
|
||||||
@@ -354,7 +354,7 @@ importers:
|
|||||||
version: 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@8.6.0)
|
version: 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@8.6.0)
|
||||||
'@tria-plc/iamui':
|
'@tria-plc/iamui':
|
||||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(e4483b467c1e4b1b4b359f6670e164a6)
|
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||||
'@types/three':
|
'@types/three':
|
||||||
specifier: ^0.185.3
|
specifier: ^0.185.3
|
||||||
version: 0.185.3
|
version: 0.185.3
|
||||||
@@ -586,13 +586,13 @@ importers:
|
|||||||
version: 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3)
|
version: 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3)
|
||||||
'@mantine/core':
|
'@mantine/core':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/dates':
|
'@mantine/dates':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.2(react@19.2.6)
|
version: 9.3.0(react@19.2.6)
|
||||||
'@posthog/react':
|
'@posthog/react':
|
||||||
specifier: ^1.10.3
|
specifier: ^1.10.3
|
||||||
version: 1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6)
|
version: 1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6)
|
||||||
@@ -601,7 +601,7 @@ importers:
|
|||||||
version: 5.101.0(react@19.2.6)
|
version: 5.101.0(react@19.2.6)
|
||||||
'@tria-plc/iamui':
|
'@tria-plc/iamui':
|
||||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(ea394f4c73a62db876565d0b255299aa)
|
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
|
||||||
'@vis.gl/react-google-maps':
|
'@vis.gl/react-google-maps':
|
||||||
specifier: ^1.8.3
|
specifier: ^1.8.3
|
||||||
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -1442,7 +1442,7 @@ importers:
|
|||||||
version: link:../types
|
version: link:../types
|
||||||
'@mantine/core':
|
'@mantine/core':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.2(@mantine/hooks@9.3.2(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 9.3.0(@mantine/hooks@9.3.0(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@tanstack/react-table':
|
'@tanstack/react-table':
|
||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -2282,6 +2282,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react-hook-form: ^7.0.0
|
react-hook-form: ^7.0.0
|
||||||
|
|
||||||
|
'@hookform/resolvers@5.4.0':
|
||||||
|
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||||
|
peerDependencies:
|
||||||
|
react-hook-form: ^7.55.0
|
||||||
|
|
||||||
'@hookform/resolvers@5.6.0':
|
'@hookform/resolvers@5.6.0':
|
||||||
resolution: {integrity: sha512-qtgE4NUK/WQFPq8aDe+GOhr0/UiUSKT0m9ta9SMnDS5ZE63yC850ShAGz1lxtwO+dBjhUKvUxmHwkp4eN7/kBQ==}
|
resolution: {integrity: sha512-qtgE4NUK/WQFPq8aDe+GOhr0/UiUSKT0m9ta9SMnDS5ZE63yC850ShAGz1lxtwO+dBjhUKvUxmHwkp4eN7/kBQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2919,6 +2924,13 @@ packages:
|
|||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
|
'@mantine/core@9.3.0':
|
||||||
|
resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@mantine/hooks': 9.3.0
|
||||||
|
react: ^19.2.0
|
||||||
|
react-dom: ^19.2.0
|
||||||
|
|
||||||
'@mantine/core@9.3.2':
|
'@mantine/core@9.3.2':
|
||||||
resolution: {integrity: sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==}
|
resolution: {integrity: sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2949,6 +2961,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
|
|
||||||
|
'@mantine/hooks@9.3.0':
|
||||||
|
resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^19.2.0
|
||||||
|
|
||||||
'@mantine/hooks@9.3.2':
|
'@mantine/hooks@9.3.2':
|
||||||
resolution: {integrity: sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==}
|
resolution: {integrity: sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5213,6 +5230,9 @@ packages:
|
|||||||
'@types/jquery@3.5.34':
|
'@types/jquery@3.5.34':
|
||||||
resolution: {integrity: sha512-3m3939S3erqmTLJANS/uy0B6V7BorKx7RorcGZVjZ62dF5PAGbKEDZK1CuLtKombJkFA2T1jl8LAIIs7IV6gBQ==}
|
resolution: {integrity: sha512-3m3939S3erqmTLJANS/uy0B6V7BorKx7RorcGZVjZ62dF5PAGbKEDZK1CuLtKombJkFA2T1jl8LAIIs7IV6gBQ==}
|
||||||
|
|
||||||
|
'@types/jquery@4.0.1':
|
||||||
|
resolution: {integrity: sha512-9a59A/tycXgYuPABcp6/3spSShn0NT2UOM4EfHvMumjYi4lJWTsK5SZWjhx3yRm9IHGCeWXdV2YfNsrWrft/CA==}
|
||||||
|
|
||||||
'@types/js-cookie@3.0.6':
|
'@types/js-cookie@3.0.6':
|
||||||
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||||
|
|
||||||
@@ -9057,6 +9077,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
js-yaml@4.2.0:
|
||||||
|
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
js-yaml@4.3.0:
|
js-yaml@4.3.0:
|
||||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -13945,7 +13969,7 @@ snapshots:
|
|||||||
globals: 13.24.0
|
globals: 13.24.0
|
||||||
ignore: 5.3.2
|
ignore: 5.3.2
|
||||||
import-fresh: 3.3.1
|
import-fresh: 3.3.1
|
||||||
js-yaml: 4.3.0
|
js-yaml: 4.2.0
|
||||||
minimatch: 3.1.5
|
minimatch: 3.1.5
|
||||||
strip-json-comments: 3.1.1
|
strip-json-comments: 3.1.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -14078,19 +14102,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react-hook-form: 7.77.0(react@18.3.1)
|
react-hook-form: 7.77.0(react@18.3.1)
|
||||||
|
|
||||||
'@hookform/resolvers@5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76)':
|
'@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.6))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/utils': 0.3.0
|
'@standard-schema/utils': 0.3.0
|
||||||
react-hook-form: 7.77.0(react@19.2.6)
|
react-hook-form: 7.77.0(react@19.2.6)
|
||||||
optionalDependencies:
|
|
||||||
'@sinclair/typebox': 0.27.10
|
|
||||||
'@standard-schema/spec': 1.1.0
|
|
||||||
ajv: 8.20.0
|
|
||||||
ajv-formats: 2.1.1(ajv@8.20.0)
|
|
||||||
class-transformer: 0.5.1
|
|
||||||
class-validator: 0.14.4
|
|
||||||
effect: 3.21.0
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
'@hookform/resolvers@5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3)':
|
'@hookform/resolvers@5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -14873,10 +14888,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
|
|
||||||
'@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react': 0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@floating-ui/react': 0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@mantine/hooks': 9.3.2(react@18.3.1)
|
'@mantine/hooks': 9.3.0(react@18.3.1)
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
@@ -14886,6 +14901,19 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
|
|
||||||
|
'@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@mantine/hooks': 9.3.0(react@19.2.6)
|
||||||
|
clsx: 2.1.1
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
react-number-format: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@19.2.6)
|
||||||
|
type-fest: 5.7.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
|
||||||
'@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
'@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -14908,6 +14936,15 @@ snapshots:
|
|||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
|
'@mantine/dates@9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@mantine/hooks': 9.3.0(react@19.2.6)
|
||||||
|
clsx: 2.1.1
|
||||||
|
dayjs: 1.11.21
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
'@mantine/dates@9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
'@mantine/dates@9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/core': 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/core': 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -14921,10 +14958,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
|
||||||
'@mantine/hooks@9.3.2(react@18.3.1)':
|
'@mantine/hooks@9.3.0(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
|
'@mantine/hooks@9.3.0(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
|
||||||
'@mantine/hooks@9.3.2(react@19.2.6)':
|
'@mantine/hooks@9.3.2(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
@@ -17919,11 +17960,11 @@ snapshots:
|
|||||||
- '@faker-js/faker'
|
- '@faker-js/faker'
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(e4483b467c1e4b1b4b359f6670e164a6)':
|
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
||||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
||||||
'@hookform/resolvers': 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76)
|
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
|
||||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -18026,29 +18067,11 @@ snapshots:
|
|||||||
- '@mui/icons-material'
|
- '@mui/icons-material'
|
||||||
- '@mui/material'
|
- '@mui/material'
|
||||||
- '@mui/x-date-pickers'
|
- '@mui/x-date-pickers'
|
||||||
- '@sinclair/typebox'
|
|
||||||
- '@standard-schema/spec'
|
|
||||||
- '@types/prop-types'
|
- '@types/prop-types'
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
- '@types/react-dom'
|
- '@types/react-dom'
|
||||||
- '@typeschema/main'
|
|
||||||
- '@vinejs/vine'
|
|
||||||
- ajv
|
|
||||||
- ajv-errors
|
|
||||||
- ajv-formats
|
|
||||||
- arktype
|
|
||||||
- ata-validator
|
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- class-transformer
|
|
||||||
- class-validator
|
|
||||||
- computed-types
|
|
||||||
- debug
|
- debug
|
||||||
- effect
|
|
||||||
- fluentvalidation-ts
|
|
||||||
- fp-ts
|
|
||||||
- io-ts
|
|
||||||
- joi
|
|
||||||
- nope-validator
|
|
||||||
- pdfjs-dist
|
- pdfjs-dist
|
||||||
- prop-types
|
- prop-types
|
||||||
- react-is
|
- react-is
|
||||||
@@ -18056,21 +18079,16 @@ snapshots:
|
|||||||
- redux
|
- redux
|
||||||
- rolldown
|
- rolldown
|
||||||
- rollup
|
- rollup
|
||||||
- superstruct
|
|
||||||
- supports-color
|
- supports-color
|
||||||
- typanion
|
|
||||||
- typescript
|
- typescript
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- valibot
|
|
||||||
- vest
|
|
||||||
- vite
|
- vite
|
||||||
- yup
|
|
||||||
|
|
||||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(ea394f4c73a62db876565d0b255299aa)':
|
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
||||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
||||||
'@hookform/resolvers': 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76)
|
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
|
||||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -18173,29 +18191,11 @@ snapshots:
|
|||||||
- '@mui/icons-material'
|
- '@mui/icons-material'
|
||||||
- '@mui/material'
|
- '@mui/material'
|
||||||
- '@mui/x-date-pickers'
|
- '@mui/x-date-pickers'
|
||||||
- '@sinclair/typebox'
|
|
||||||
- '@standard-schema/spec'
|
|
||||||
- '@types/prop-types'
|
- '@types/prop-types'
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
- '@types/react-dom'
|
- '@types/react-dom'
|
||||||
- '@typeschema/main'
|
|
||||||
- '@vinejs/vine'
|
|
||||||
- ajv
|
|
||||||
- ajv-errors
|
|
||||||
- ajv-formats
|
|
||||||
- arktype
|
|
||||||
- ata-validator
|
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- class-transformer
|
|
||||||
- class-validator
|
|
||||||
- computed-types
|
|
||||||
- debug
|
- debug
|
||||||
- effect
|
|
||||||
- fluentvalidation-ts
|
|
||||||
- fp-ts
|
|
||||||
- io-ts
|
|
||||||
- joi
|
|
||||||
- nope-validator
|
|
||||||
- pdfjs-dist
|
- pdfjs-dist
|
||||||
- prop-types
|
- prop-types
|
||||||
- react-is
|
- react-is
|
||||||
@@ -18203,15 +18203,10 @@ snapshots:
|
|||||||
- redux
|
- redux
|
||||||
- rolldown
|
- rolldown
|
||||||
- rollup
|
- rollup
|
||||||
- superstruct
|
|
||||||
- supports-color
|
- supports-color
|
||||||
- typanion
|
|
||||||
- typescript
|
- typescript
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- valibot
|
|
||||||
- vest
|
|
||||||
- vite
|
- vite
|
||||||
- yup
|
|
||||||
|
|
||||||
'@ts-morph/common@0.27.0':
|
'@ts-morph/common@0.27.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18411,6 +18406,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/sizzle': 2.3.10
|
'@types/sizzle': 2.3.10
|
||||||
|
|
||||||
|
'@types/jquery@4.0.1': {}
|
||||||
|
|
||||||
'@types/js-cookie@3.0.6': {}
|
'@types/js-cookie@3.0.6': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -18549,7 +18546,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/tinymce@4.6.9':
|
'@types/tinymce@4.6.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/jquery': 3.5.34
|
'@types/jquery': 4.0.1
|
||||||
|
|
||||||
'@types/tmp@0.2.6': {}
|
'@types/tmp@0.2.6': {}
|
||||||
|
|
||||||
@@ -20155,7 +20152,7 @@ snapshots:
|
|||||||
cosmiconfig@8.3.6(typescript@5.9.3):
|
cosmiconfig@8.3.6(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
import-fresh: 3.3.1
|
import-fresh: 3.3.1
|
||||||
js-yaml: 4.3.0
|
js-yaml: 4.2.0
|
||||||
parse-json: 5.2.0
|
parse-json: 5.2.0
|
||||||
path-type: 4.0.0
|
path-type: 4.0.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -20165,7 +20162,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
env-paths: 2.2.1
|
env-paths: 2.2.1
|
||||||
import-fresh: 3.3.1
|
import-fresh: 3.3.1
|
||||||
js-yaml: 4.3.0
|
js-yaml: 4.2.0
|
||||||
parse-json: 5.2.0
|
parse-json: 5.2.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
@@ -20888,7 +20885,7 @@ snapshots:
|
|||||||
'@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.10
|
eslint-import-resolver-node: 0.3.10
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
||||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||||
@@ -20912,7 +20909,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
|
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
@@ -20927,14 +20924,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7(supports-color@8.1.1)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.10
|
eslint-import-resolver-node: 0.3.10
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -20949,7 +20946,7 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.10
|
eslint-import-resolver-node: 0.3.10
|
||||||
eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||||
hasown: 2.0.4
|
hasown: 2.0.4
|
||||||
is-core-module: 2.16.2
|
is-core-module: 2.16.2
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -21061,7 +21058,7 @@ snapshots:
|
|||||||
imurmurhash: 0.1.4
|
imurmurhash: 0.1.4
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
is-path-inside: 3.0.3
|
is-path-inside: 3.0.3
|
||||||
js-yaml: 4.3.0
|
js-yaml: 4.2.0
|
||||||
json-stable-stringify-without-jsonify: 1.0.1
|
json-stable-stringify-without-jsonify: 1.0.1
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
lodash.merge: 4.6.2
|
lodash.merge: 4.6.2
|
||||||
@@ -22896,6 +22893,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
|
|
||||||
|
js-yaml@4.2.0:
|
||||||
|
dependencies:
|
||||||
|
argparse: 2.0.1
|
||||||
|
|
||||||
js-yaml@4.3.0:
|
js-yaml@4.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user