mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
eims integration master test complete
This commit is contained in:
@@ -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.",
|
||||
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()
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
@Length(1, 500)
|
||||
irn?: string;
|
||||
|
||||
@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);
|
||||
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,
|
||||
transactionType: invoice.transactionType,
|
||||
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
||||
// One treatment for every line today. The mapper resolves tax per line, so a future
|
||||
// charge-type-specific rule slots in here without touching the mapper.
|
||||
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }),
|
||||
// Per-`chargeType` override when one is configured (validated symmetric in
|
||||
// assertChargeTypeOverrides), else the single invoice-wide default.
|
||||
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,
|
||||
unitDefault: invoice.unitDefault,
|
||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||
@@ -145,6 +202,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
||||
buyerCountryCode: invoice.buyerCountryCode,
|
||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||
buyerIdType: invoice.buyerIdType,
|
||||
buyerIdNumber: invoice.buyerIdNumber,
|
||||
exchangeRate: input.exchangeRate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
@@ -85,6 +86,8 @@ class FakeDb {
|
||||
state: EimsSystemState | null = null;
|
||||
/** Runs before every transaction body, to simulate a concurrent writer. */
|
||||
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>) {
|
||||
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||
@@ -133,10 +136,13 @@ class FakeDb {
|
||||
return {
|
||||
manager: this.manager,
|
||||
getRepository: this.manager.getRepository,
|
||||
query: async (sql: string) =>
|
||||
sql.includes("eims_system_state")
|
||||
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
|
||||
: LINES,
|
||||
query: async (sql: string) => {
|
||||
if (sql.includes("eims_system_state")) {
|
||||
return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }];
|
||||
}
|
||||
if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : [];
|
||||
return LINES;
|
||||
},
|
||||
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
||||
this.onTransaction?.();
|
||||
return body(this.manager);
|
||||
@@ -155,6 +161,7 @@ const build = (
|
||||
postBearer: jest.Mock = jest.fn(),
|
||||
getSessionContext: jest.Mock | undefined = undefined,
|
||||
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||
) =>
|
||||
new EimsInvoiceRegistrationService(
|
||||
db.asDataSource(),
|
||||
@@ -164,6 +171,7 @@ const build = (
|
||||
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
|
||||
} as unknown as EimsAuthService,
|
||||
{ notify } as unknown as NotificationInboxService,
|
||||
{ directSend } as unknown as NotificationsService,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -196,8 +204,11 @@ const verifyResponse = (over: Record<string, unknown> = {}) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const okResponse = (irn = IRN) =>
|
||||
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
|
||||
const okResponse = (irn = IRN, over: Record<string, unknown> = {}) => ({
|
||||
statusCode: 200,
|
||||
message: "SUCCESS",
|
||||
body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over },
|
||||
});
|
||||
|
||||
const apiError = (kind: string, status?: number) =>
|
||||
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 () => {
|
||||
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||
@@ -408,7 +464,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
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 postSigned = jest
|
||||
.fn()
|
||||
@@ -421,14 +477,47 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
);
|
||||
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
||||
|
||||
// The two numbers move differently, because MoR constrains them differently: the counter must
|
||||
// not skip (it returns), the document number must not repeat (it is burned).
|
||||
// A deterministic rejection rolls both numbers back — MoR's expected-next-value for either
|
||||
// sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter).
|
||||
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
|
||||
expect(first.SourceSystem.InvoiceCounter).toBe(7);
|
||||
expect(second.SourceSystem.InvoiceCounter).toBe(7);
|
||||
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";
|
||||
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
|
||||
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 { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
@@ -78,6 +81,7 @@ export class EimsInvoiceRegistrationService {
|
||||
private readonly client: EimsClientService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
@@ -115,17 +119,28 @@ export class EimsInvoiceRegistrationService {
|
||||
|
||||
let irn: string;
|
||||
let ackDate: string | undefined;
|
||||
let signedQR: string | undefined;
|
||||
try {
|
||||
// Deliberately outside every transaction — no DB lock is held across the wire.
|
||||
const result = await this.submit(request);
|
||||
irn = result.irn;
|
||||
ackDate = result.ackDate;
|
||||
signedQR = result.signedQR;
|
||||
} catch (err) {
|
||||
await this.settleFailure(invoiceId, reservation, 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(
|
||||
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
|
||||
);
|
||||
@@ -373,13 +388,19 @@ export class EimsInvoiceRegistrationService {
|
||||
reservation: Reservation,
|
||||
irn: string,
|
||||
ackDate?: string,
|
||||
signedQR?: string,
|
||||
): Promise<void> {
|
||||
let companyId: string | undefined;
|
||||
let invoiceNumber = invoiceId;
|
||||
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, {
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
eimsIrn: irn,
|
||||
eimsAckDate: ackDate ?? null,
|
||||
eimsSignedQr: signedQR ?? null,
|
||||
eimsLastError: null,
|
||||
});
|
||||
await manager.update(EimsSystemState, reservation.stateId, {
|
||||
@@ -390,20 +411,39 @@ export class EimsInvoiceRegistrationService {
|
||||
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
|
||||
* 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
|
||||
* document MoR definitively refused was never counted there, so ours must not advance either.
|
||||
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
|
||||
* unique". It is therefore spent by the attempt itself and never handed back, even for a
|
||||
* refusal.
|
||||
* - `InvoiceCounter`: "Invoice counter is not correct. expected : 1".
|
||||
* - `DocumentNumber`: "Document number error. Document number is not in correct sequence
|
||||
* expected : 1" (rule 7001) — confirmed live 2026-08-12. An earlier design burned
|
||||
* `DocumentNumber` forward on every attempt, reasoning from a separate "Document number is
|
||||
* 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.
|
||||
*/
|
||||
@@ -434,9 +474,9 @@ export class EimsInvoiceRegistrationService {
|
||||
reservation.stateId,
|
||||
deterministic
|
||||
? {
|
||||
// Counter returns (MoR never counted a refused document); the document number does
|
||||
// not (MoR requires it to be unique, so it is burned by the attempt).
|
||||
// Both return: MoR never counted a refused document against either sequence.
|
||||
nextInvoiceCounter: reservation.invoiceCounter,
|
||||
nextDocumentNumber: Number(reservation.documentNumber),
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
inFlightDocumentNumber: null,
|
||||
@@ -455,6 +495,53 @@ export class EimsInvoiceRegistrationService {
|
||||
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.
|
||||
*
|
||||
@@ -492,7 +579,9 @@ export class EimsInvoiceRegistrationService {
|
||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 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>(
|
||||
"/v1/register",
|
||||
request,
|
||||
@@ -506,7 +595,7 @@ export class EimsInvoiceRegistrationService {
|
||||
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> {
|
||||
@@ -572,17 +661,6 @@ export class EimsInvoiceRegistrationService {
|
||||
}
|
||||
|
||||
private toView(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,
|
||||
};
|
||||
return toEimsInvoiceStatusView(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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 { EimsCancellationService } from "./eims-cancellation.service";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsReceiptService } from "./eims-receipt.service";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* to named admins instead. They are also separate permissions: resolving clears the system-wide
|
||||
* chain block and can record an IRN against an invoice, which is a supervisor action, not an
|
||||
* operational one. Only `eims/status` rides on the ordinary `invoices:view`.
|
||||
* `eims_register`, `eims_resolve`, `eims_cancel` and `eims_receipt_register` are intentionally left
|
||||
* out of every role preset and assigned to named admins instead. They are also separate
|
||||
* permissions: resolving clears the system-wide chain block and can record an IRN against an
|
||||
* 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:
|
||||
* 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()
|
||||
@Controller("invoices")
|
||||
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")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||
@@ -65,4 +76,38 @@ export class EimsInvoiceController {
|
||||
status(@Param("id", ParseUUIDPipe) id: string) {
|
||||
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",
|
||||
Failed = "FAILED",
|
||||
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. */
|
||||
@@ -65,6 +67,28 @@ export interface EimsVerifyResponse {
|
||||
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. */
|
||||
export interface EimsInvoiceError {
|
||||
kind: string;
|
||||
@@ -86,4 +110,10 @@ export interface EimsInvoiceStatusView {
|
||||
eimsSubmittedAt: Date | null;
|
||||
eimsAckDate: string | 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,
|
||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||
taxCodeByChargeType: {},
|
||||
taxRateByChargeType: {},
|
||||
exciseByChargeType: {},
|
||||
discountByChargeType: {},
|
||||
cashierName: null,
|
||||
salesPersonName: null,
|
||||
buyerIdType: null,
|
||||
buyerIdNumber: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,17 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||
import { EimsInvoiceController } from "./eims-invoice.controller";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsReceiptService } from "./eims-receipt.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
|
||||
/**
|
||||
@@ -22,8 +26,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
||||
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
|
||||
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
||||
NotificationInboxModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [EimsInvoiceController],
|
||||
providers: [
|
||||
@@ -33,7 +38,15 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
EimsClientService,
|
||||
EimsInvoiceRegistrationService,
|
||||
EimsAutoSubmitService,
|
||||
EimsCancellationService,
|
||||
EimsReceiptService,
|
||||
],
|
||||
exports: [
|
||||
EimsAuthService,
|
||||
EimsClientService,
|
||||
EimsInvoiceRegistrationService,
|
||||
EimsCancellationService,
|
||||
EimsReceiptService,
|
||||
],
|
||||
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
|
||||
})
|
||||
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 })
|
||||
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;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user