mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(eims): printable receipt PDF with RRN and QR
eims-receipt-document.mapper.ts maps an EimsReceipt onto the shared InvoiceDocumentModel layout, reading amounts back out of the stored request body. Refuses to render anything not REGISTERED. GET invoices/:id/eims/receipts/:receiptId/document, scoped to the invoice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,15 @@ import {
|
|||||||
|
|
||||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MoR returns `signedQR`/`qr` as a base64 PNG already rendered server-side — confirmed against the
|
||||||
|
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), not a
|
||||||
|
* payload we encode ourselves. Wrap, don't encode. Shared by `Invoice.eimsSignedQr`
|
||||||
|
* (`BillingService`) and `EimsReceipt.qr` (`eims-receipt-document.mapper.ts`) — same convention,
|
||||||
|
* same gateway.
|
||||||
|
*/
|
||||||
|
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
|
||||||
|
|
||||||
/** One billed line on the document (charge type / fee type agnostic). */
|
/** One billed line on the document (charge type / fee type agnostic). */
|
||||||
export interface InvoiceDocumentLine {
|
export interface InvoiceDocumentLine {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import type { Response } from "express";
|
||||||
|
|
||||||
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 { sendPdf } from "../billing/billing.controller";
|
||||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
@@ -110,4 +112,16 @@ export class EimsInvoiceController {
|
|||||||
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.receipts.listReceipts(id);
|
return this.receipts.listReceipts(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(":id/eims/receipts/:receiptId/document")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||||
|
@ApiOperation({ summary: "Download the sealed receipt PDF (RRN + QR) for a filed EIMS receipt" })
|
||||||
|
async receiptDocument(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("receiptId", ParseUUIDPipe) receiptId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.receipts.document(id, receiptId);
|
||||||
|
sendPdf(res, filename, buffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import {
|
||||||
|
InvoiceDocumentModel,
|
||||||
|
pngDataUrl,
|
||||||
|
} from "../billing/documents/invoice-document.service";
|
||||||
|
import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||||
|
import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a filed `EimsReceipt` onto the shared invoice/receipt document layout — mirrors
|
||||||
|
* `eims-invoice.mapper.ts`'s role for `/v1/register`: a pure function, no I/O.
|
||||||
|
*
|
||||||
|
* The amounts (collected amount, mode of payment, withholding amount) live only in
|
||||||
|
* `receipt.request` — the exact body this app sent, typed and written in exactly one place
|
||||||
|
* (`EimsReceiptService`). Reading it back is a cast, not a new source of truth; real columns
|
||||||
|
* would mean a migration + backfill for data already present in a stable shape.
|
||||||
|
*
|
||||||
|
* Throws rather than returning a model for anything not actually filed: a sealed, stamped PDF
|
||||||
|
* for a receipt MoR rejected, never acknowledged, or whose request was somehow never recorded
|
||||||
|
* would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw
|
||||||
|
* surface as a 400 — there is nothing sensible to render instead.
|
||||||
|
*/
|
||||||
|
export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel {
|
||||||
|
if (receipt.status !== EimsReceiptStatus.Registered) {
|
||||||
|
throw new Error(
|
||||||
|
`Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!receipt.request) {
|
||||||
|
throw new Error(`Receipt ${receipt.receiptNumber} has no stored request body — cannot render its amounts.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSales = receipt.kind === "SALES";
|
||||||
|
|
||||||
|
if (isSales) {
|
||||||
|
const req = receipt.request as unknown as EimsSalesReceiptRequest;
|
||||||
|
return build(receipt, invoice, {
|
||||||
|
title: "Sales Receipt",
|
||||||
|
currency: req.ReceiptCurrency,
|
||||||
|
amountLabel: "Collected",
|
||||||
|
lineDescription: `Payment received against invoice ${invoice.invoiceNumber}`,
|
||||||
|
amount: req.CollectedAmount,
|
||||||
|
// A sales receipt is a real payment — this is the one case the shared layout's own default
|
||||||
|
// ("EDR PAID" for kind RECEIPT) is already correct, but set it explicitly so it never drifts
|
||||||
|
// if that default changes for an unrelated reason.
|
||||||
|
sealText: "EDR PAID",
|
||||||
|
extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = receipt.request as unknown as EimsWithholdReceiptRequest;
|
||||||
|
return build(receipt, invoice, {
|
||||||
|
title: "Withholding Receipt",
|
||||||
|
currency: req.InvoiceDetail.Currency,
|
||||||
|
amountLabel: "Withheld",
|
||||||
|
lineDescription: `Withholding (${req.WithholdDetail.Type}) against invoice ${invoice.invoiceNumber}`,
|
||||||
|
amount: req.WithholdDetail.WithholdingAmount,
|
||||||
|
// A withholding receipt is not a payment — the shared layout's "EDR PAID" default would be
|
||||||
|
// wrong here, so this is the one case that MUST override it.
|
||||||
|
sealText: "EDR",
|
||||||
|
extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(
|
||||||
|
receipt: EimsReceipt,
|
||||||
|
invoice: Invoice,
|
||||||
|
opts: {
|
||||||
|
title: string;
|
||||||
|
currency: string;
|
||||||
|
amountLabel: string;
|
||||||
|
lineDescription: string;
|
||||||
|
amount: number;
|
||||||
|
sealText: string;
|
||||||
|
extraSummary: Array<{ label: string; value: string | null }>;
|
||||||
|
},
|
||||||
|
): InvoiceDocumentModel {
|
||||||
|
return {
|
||||||
|
kind: "RECEIPT",
|
||||||
|
title: opts.title,
|
||||||
|
documentNumber: receipt.receiptNumber,
|
||||||
|
issuedAt: receipt.submittedAt ?? null,
|
||||||
|
status: receipt.status,
|
||||||
|
currency: opts.currency,
|
||||||
|
summary: [
|
||||||
|
{ label: "Invoice", value: invoice.invoiceNumber },
|
||||||
|
{ label: "Invoice IRN", value: invoice.eimsIrn ?? null },
|
||||||
|
{ label: "RRN", value: receipt.rrn ?? null },
|
||||||
|
{ label: "Ack status", value: receipt.ackStatus ?? null },
|
||||||
|
...opts.extraSummary,
|
||||||
|
],
|
||||||
|
// No line items on a receipt — one synthetic line, since buildHtml renders the line table
|
||||||
|
// unconditionally and an empty `lines: []` would print a header-only empty table.
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
description: opts.lineDescription,
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: opts.amount,
|
||||||
|
amount: opts.amount,
|
||||||
|
currency: opts.currency,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totals: [{ label: opts.amountLabel, amount: opts.amount, grand: true }],
|
||||||
|
sealText: opts.sealText,
|
||||||
|
qrImageUrl: receipt.qr ? pngDataUrl(receipt.qr) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { DataSource } from "typeorm";
|
|||||||
|
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||||
import { NotificationsService } from "../notifications/notifications.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";
|
||||||
@@ -40,10 +41,16 @@ class FakeDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private manager = {
|
private manager = {
|
||||||
findOne: async (entity: unknown, options: { where: { id: string } }) =>
|
findOne: async (
|
||||||
entity === Invoice
|
entity: unknown,
|
||||||
? (this.invoices.get(options.where.id) ?? null)
|
options: { where: { id?: string; invoiceId?: string } },
|
||||||
: (this.receipts.get(options.where.id) ?? null),
|
) => {
|
||||||
|
if (entity === Invoice) return this.invoices.get(options.where.id!) ?? null;
|
||||||
|
const receipt = options.where.id ? this.receipts.get(options.where.id) : undefined;
|
||||||
|
if (!receipt) return null;
|
||||||
|
if (options.where.invoiceId && receipt.invoiceId !== options.where.invoiceId) return null;
|
||||||
|
return receipt;
|
||||||
|
},
|
||||||
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
|
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
|
||||||
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
|
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
|
||||||
save: async (_entity: unknown, data: Record<string, unknown>) => {
|
save: async (_entity: unknown, data: Record<string, unknown>) => {
|
||||||
@@ -71,6 +78,7 @@ const build = (
|
|||||||
db: FakeDb,
|
db: FakeDb,
|
||||||
postBearer: jest.Mock,
|
postBearer: jest.Mock,
|
||||||
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||||
|
documents: { render: jest.Mock } = { render: jest.fn() },
|
||||||
) =>
|
) =>
|
||||||
new EimsReceiptService(
|
new EimsReceiptService(
|
||||||
db.asDataSource(),
|
db.asDataSource(),
|
||||||
@@ -78,6 +86,7 @@ const build = (
|
|||||||
{ postBearer } as unknown as EimsClientService,
|
{ postBearer } as unknown as EimsClientService,
|
||||||
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
||||||
{ directSend } as unknown as NotificationsService,
|
{ directSend } as unknown as NotificationsService,
|
||||||
|
documents as unknown as InvoiceDocumentService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const okResponse = (over: Record<string, unknown> = {}) => ({
|
const okResponse = (over: Record<string, unknown> = {}) => ({
|
||||||
@@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => {
|
|||||||
expect(list).toHaveLength(2);
|
expect(list).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("EimsReceiptService.document", () => {
|
||||||
|
it("renders a sealed PDF for a registered sales receipt, with RRN and QR in the model", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
|
||||||
|
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
|
||||||
|
const receipt = await service.registerSalesReceipt(INVOICE_ID, {
|
||||||
|
modeOfPayment: "CASH",
|
||||||
|
collectedAmount: 500,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await service.document(INVOICE_ID, receipt.id);
|
||||||
|
|
||||||
|
expect(documents.render).toHaveBeenCalledTimes(1);
|
||||||
|
const model = documents.render.mock.calls[0][0];
|
||||||
|
expect(model.kind).toBe("RECEIPT");
|
||||||
|
expect(model.qrImageUrl).toBe("data:image/png;base64,iVBORw0KGgo...");
|
||||||
|
expect(model.summary).toContainEqual({ label: "RRN", value: "rrn-value" });
|
||||||
|
expect(model.lines[0].amount).toBe(500);
|
||||||
|
expect(model.sealText).toBe("EDR PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a withholding receipt with the withheld amount and a non-PAID seal", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
|
||||||
|
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
|
||||||
|
const receipt = await service.registerWithholdingReceipt(INVOICE_ID, {
|
||||||
|
type: "TWHT",
|
||||||
|
preTaxAmount: 1000,
|
||||||
|
withholdingAmount: 20,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await service.document(INVOICE_ID, receipt.id);
|
||||||
|
|
||||||
|
const model = documents.render.mock.calls[0][0];
|
||||||
|
expect(model.lines[0].amount).toBe(20);
|
||||||
|
expect(model.sealText).toBe("EDR");
|
||||||
|
expect(model.sealText).not.toContain("PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to render a receipt that was never acknowledged by MoR", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out"));
|
||||||
|
const documents = { render: jest.fn() };
|
||||||
|
const service = build(db, postBearer, undefined, documents);
|
||||||
|
await expect(
|
||||||
|
service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
|
||||||
|
).rejects.toBeInstanceOf(EimsApiException);
|
||||||
|
const [receipt] = [...db.receipts.values()];
|
||||||
|
|
||||||
|
await expect(service.document(INVOICE_ID, receipt.id as string)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(documents.render).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes the lookup to the given invoice — a receipt from another invoice is not found", async () => {
|
||||||
|
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||||
|
const service = build(db, jest.fn().mockResolvedValue(okResponse()));
|
||||||
|
const receipt = await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
|
||||||
|
|
||||||
|
await expect(service.document(OTHER_INVOICE_ID, receipt.id)).rejects.toThrow(/not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
|
|||||||
|
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
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";
|
||||||
|
import { toReceiptDocumentModel } from "./eims-receipt-document.mapper";
|
||||||
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
@@ -52,6 +54,7 @@ export class EimsReceiptService {
|
|||||||
private readonly client: EimsClientService,
|
private readonly client: EimsClientService,
|
||||||
private readonly auth: EimsAuthService,
|
private readonly auth: EimsAuthService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly documents: InvoiceDocumentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get cfg(): EimsConfig {
|
private get cfg(): EimsConfig {
|
||||||
@@ -154,6 +157,31 @@ export class EimsReceiptService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed PDF for one filed receipt (RRN + QR), scoped to the invoice it belongs to. Not on
|
||||||
|
* `loadRegisteredInvoice` — a receipt refused/never-acknowledged by MoR must not render as a
|
||||||
|
* sealed tax document, and `toReceiptDocumentModel` is the one place that guards it.
|
||||||
|
*/
|
||||||
|
async document(invoiceId: string, receiptId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const receipt = await this.dataSource.manager.findOne(EimsReceipt, {
|
||||||
|
where: { id: receiptId, invoiceId },
|
||||||
|
});
|
||||||
|
if (!receipt) throw new NotFoundException(`Receipt ${receiptId} not found on invoice ${invoiceId}`);
|
||||||
|
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
|
||||||
|
let model: ReturnType<typeof toReceiptDocumentModel>;
|
||||||
|
try {
|
||||||
|
model = toReceiptDocumentModel(receipt, invoice);
|
||||||
|
} catch (err) {
|
||||||
|
// Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 —
|
||||||
|
// a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService
|
||||||
|
// itself throws.
|
||||||
|
throw new BadRequestException((err as Error).message);
|
||||||
|
}
|
||||||
|
return this.documents.render(model);
|
||||||
|
}
|
||||||
|
|
||||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async submit(
|
private async submit(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
|
import { DocumentsModule } from "../billing/documents/documents.module";
|
||||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { NotificationsModule } from "../notifications/notifications.module";
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
@@ -29,6 +30,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
// For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain
|
||||||
|
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
|
||||||
|
DocumentsModule,
|
||||||
],
|
],
|
||||||
controllers: [EimsInvoiceController],
|
controllers: [EimsInvoiceController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
Reference in New Issue
Block a user