mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(billing): issue credit/debit memos against registered invoices
POST billing/invoices/:id/memo files a MoR DEB/CRE memo by reusing createInvoice unchanged. sourceId is the original invoice's own id, not its source's — this structurally keeps memos out of findPayable/expirePayable/ billQuery's sourceId-keyed lookups regardless of status. Credit notes are created settled; debit notes are created open/unpaid as a genuine new receivable, not force-settled. memoIssue is granted to the chief position, not the general finance role. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import { actorLabel } from "../warehouses/current-actor.util";
|
||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
import { IssueMemoDto } from "./dto/issue-memo.dto";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@@ -38,6 +39,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
FREIGHT_PERMS.invoices.memoIssue,
|
||||
])
|
||||
@ApiBearerAuth()
|
||||
export class BillingController {
|
||||
@@ -99,6 +101,16 @@ export class BillingController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post("invoices/:id/memo")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.memoIssue)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.",
|
||||
})
|
||||
issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) {
|
||||
return this.billingService.issueMemo(id, dto);
|
||||
}
|
||||
|
||||
@Get("invoices/:id/document")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
|
||||
@@ -119,6 +119,159 @@ describe("BillingService.generateInvoice", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.issueMemo", () => {
|
||||
const ORIGINAL_ID = "original-invoice-1";
|
||||
|
||||
function originalInvoice(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: ORIGINAL_ID,
|
||||
invoiceNumber: "INV-20260807-00042",
|
||||
eimsIrn: "irn-value",
|
||||
eimsDocumentType: "INV",
|
||||
eimsStatus: "REGISTERED",
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
companyId: "company-1",
|
||||
companyProfileId: "profile-1",
|
||||
shippingLineCompanyId: null,
|
||||
currency: "ETB",
|
||||
totalAmount: 1500,
|
||||
lines: [
|
||||
{ chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null },
|
||||
{ chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function build(original: ReturnType<typeof originalInvoice>) {
|
||||
const savedLines: unknown[] = [];
|
||||
const manager = makeManager(savedLines);
|
||||
const dataSource = {
|
||||
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||
manager,
|
||||
};
|
||||
const invoices = { findById: jest.fn().mockResolvedValue(original) };
|
||||
const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) };
|
||||
const service = new BillingService(
|
||||
dataSource as never,
|
||||
invoices as never,
|
||||
invoiceLines as never,
|
||||
makeEvents() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ get: () => undefined } as never,
|
||||
);
|
||||
return { service, manager, savedLines };
|
||||
}
|
||||
|
||||
it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => {
|
||||
const { service, savedLines } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" });
|
||||
|
||||
expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/);
|
||||
expect(memo.totalAmount).toBe(1500);
|
||||
expect(memo.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect((memo as unknown as Record<string, unknown>).eimsDocumentType).toBe("CRE");
|
||||
expect((memo as unknown as Record<string, unknown>).eimsReason).toBe("Overbilled freight charge");
|
||||
expect((memo as unknown as Record<string, unknown>).relatedInvoiceId).toBe(ORIGINAL_ID);
|
||||
expect((memo as unknown as Record<string, unknown>).paidAmount).toBe(1500);
|
||||
expect((memo as unknown as Record<string, unknown>).balanceAmount).toBe(0);
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" });
|
||||
|
||||
expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/);
|
||||
expect(memo.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
expect(memo.balanceAmount).toBe(1500);
|
||||
expect(memo.paidAmount).toBe(0);
|
||||
});
|
||||
|
||||
it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" });
|
||||
|
||||
expect(memo.sourceId).toBe(ORIGINAL_ID);
|
||||
expect(memo.sourceId).not.toBe("booking-1");
|
||||
});
|
||||
|
||||
it("allows a partial memo with explicit lines instead of copying the original", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, {
|
||||
type: "CRE",
|
||||
reason: "Partial credit",
|
||||
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }],
|
||||
});
|
||||
|
||||
expect(memo.totalAmount).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses a memo against an invoice never registered with EIMS", async () => {
|
||||
const { service } = build(originalInvoice({ eimsIrn: null }));
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a memo against a memo", async () => {
|
||||
const { service } = build(originalInvoice({ eimsDocumentType: "CRE" }));
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow(
|
||||
"cannot issue a memo against a memo",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a memo against an EIMS-cancelled invoice", async () => {
|
||||
const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" }));
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow(
|
||||
"cancelled with EIMS",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a credit memo whose total exceeds the original", async () => {
|
||||
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
||||
|
||||
await expect(
|
||||
service.issueMemo(ORIGINAL_ID, {
|
||||
type: "CRE",
|
||||
reason: "too much",
|
||||
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }],
|
||||
}),
|
||||
).rejects.toThrow(/exceeds/);
|
||||
});
|
||||
|
||||
it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => {
|
||||
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, {
|
||||
type: "DEB",
|
||||
reason: "additional charge",
|
||||
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }],
|
||||
});
|
||||
|
||||
expect(memo.totalAmount).toBe(5000);
|
||||
});
|
||||
|
||||
it("refuses a blank reason", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow(
|
||||
"requires a reason",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.markInvoiceAsPaid", () => {
|
||||
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
@@ -25,6 +26,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
@@ -145,6 +147,18 @@ export interface GenerateInvoiceInput {
|
||||
status?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */
|
||||
export type MemoType = "CRE" | "DEB";
|
||||
|
||||
/** Everything needed to issue a credit or debit memo against an already-registered invoice. */
|
||||
export interface IssueMemoInput {
|
||||
type: MemoType;
|
||||
/** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */
|
||||
reason: string;
|
||||
/** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */
|
||||
lines?: InvoiceLineInput[];
|
||||
}
|
||||
|
||||
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||
export interface InvoiceEventPayload {
|
||||
invoiceId: string;
|
||||
@@ -418,15 +432,6 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
|
||||
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
|
||||
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
|
||||
*/
|
||||
private renderEimsQr(signedQr: string): string {
|
||||
return `data:image/png;base64,${signedQr}`;
|
||||
}
|
||||
|
||||
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
|
||||
private async bookingSummaryRows(
|
||||
invoice: Invoice,
|
||||
@@ -542,7 +547,7 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -709,11 +714,16 @@ export class BillingService {
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
/**
|
||||
* `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
|
||||
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
|
||||
* daily sequence (different prefix hashes to a different advisory lock, see
|
||||
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
|
||||
*/
|
||||
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
|
||||
return nextDailyInvoiceNumber(mg, {
|
||||
table: "freight.invoices",
|
||||
code: "INV",
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -736,9 +746,123 @@ export class BillingService {
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
|
||||
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
|
||||
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
|
||||
* unchanged: it has no side effects (no events, no notifications, no payment records — every
|
||||
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
|
||||
* is just an ordinary invoice with three extra columns set.
|
||||
*
|
||||
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
|
||||
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
|
||||
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
|
||||
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
|
||||
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
|
||||
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
|
||||
*
|
||||
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
|
||||
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
|
||||
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
|
||||
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
|
||||
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
|
||||
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
|
||||
*/
|
||||
async issueMemo(
|
||||
originalId: string,
|
||||
input: IssueMemoInput,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const reason = input.reason?.trim();
|
||||
if (!reason) {
|
||||
throw new BadRequestException("A memo requires a reason.");
|
||||
}
|
||||
|
||||
const original = await this.findById(originalId);
|
||||
if (!original.eimsIrn) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
||||
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
|
||||
});
|
||||
}
|
||||
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
|
||||
throw new BadRequestException(
|
||||
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
|
||||
);
|
||||
}
|
||||
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException(
|
||||
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceLines = input.lines?.length ? input.lines : original.lines;
|
||||
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
|
||||
chargeType: l.chargeType,
|
||||
description: l.description,
|
||||
quantity: Number(l.quantity),
|
||||
unitRate: Number(l.unitRate),
|
||||
amount: Number(l.amount),
|
||||
currency: l.currency,
|
||||
metadata: l.metadata ?? null,
|
||||
}));
|
||||
|
||||
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
|
||||
if (!(total > 0)) {
|
||||
throw new BadRequestException("A memo must have a positive total.");
|
||||
}
|
||||
// Only a credit note is bounded by the original — it can only give back what was charged. A
|
||||
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
|
||||
// assume the credit-note ceiling is correct for DEB).
|
||||
if (input.type === "CRE" && total > Number(original.totalAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = input.type === "CRE" ? "CRE" : "DEB";
|
||||
const settled = input.type === "CRE";
|
||||
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const memo = await this.createInvoice(
|
||||
{
|
||||
source: original.source as Freight.InvoiceSource,
|
||||
sourceId: original.id,
|
||||
type: input.type === "CRE" ? "credit_note" : "debit_note",
|
||||
companyId: original.companyId,
|
||||
companyProfileId: original.companyProfileId,
|
||||
shippingLineCompanyId: original.shippingLineCompanyId,
|
||||
lines,
|
||||
currency: original.currency,
|
||||
subtotalAmount: total,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
|
||||
},
|
||||
mg,
|
||||
code,
|
||||
);
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
eimsDocumentType: input.type,
|
||||
eimsReason: reason,
|
||||
relatedInvoiceId: original.id,
|
||||
...(settled
|
||||
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, memo.id, patch);
|
||||
|
||||
this.logger.log(
|
||||
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
|
||||
);
|
||||
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
|
||||
});
|
||||
}
|
||||
|
||||
private async createInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
mg: EntityManager,
|
||||
code = "INV",
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const currency = input.currency ?? "ETB";
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
@@ -786,7 +910,7 @@ export class BillingService {
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
||||
|
||||
const invoice = await mg.save(
|
||||
mg.create(Invoice, {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
/** One line on a memo; omit the whole `lines` array on the parent DTO to copy the original's. */
|
||||
export class MemoLineDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
chargeType!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
unitRate?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
amount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** `POST billing/invoices/:id/memo` body — see `BillingService.issueMemo`. */
|
||||
export class IssueMemoDto {
|
||||
@ApiProperty({ enum: ["CRE", "DEB"], description: "MoR DocumentDetails.Type for the memo." })
|
||||
@IsIn(["CRE", "DEB"])
|
||||
type!: "CRE" | "DEB";
|
||||
|
||||
@ApiProperty({ description: "Why the memo was issued — MoR DocumentDetails.Reason." })
|
||||
@IsString()
|
||||
@Length(1, 500)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [MemoLineDto],
|
||||
description: "Omit to copy every line of the original invoice verbatim.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MemoLineDto)
|
||||
lines?: MemoLineDto[];
|
||||
}
|
||||
@@ -569,6 +569,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:invoices:eims_receipt_register",
|
||||
"Register a sales or withholding receipt with MoR EIMS",
|
||||
),
|
||||
// Issuing a credit/debit memo is itself filing-equivalent — auto-submit picks it up like any
|
||||
// other issued invoice — so it carries the same restricted grant as the eims_* actions above,
|
||||
// not invoices:export.
|
||||
perm(
|
||||
"d2b00001-0001-4000-8000-00000000000a",
|
||||
"edr_freight_app:invoices:memo_issue",
|
||||
"Issue a credit or debit memo against a registered invoice",
|
||||
),
|
||||
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
||||
// the invoice. Moves money state, so it is its own grant, not part of view.
|
||||
perm(
|
||||
@@ -1874,6 +1882,7 @@ export const FREIGHT_PERMS = {
|
||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||
memoIssue: "edr_freight_app:invoices:memo_issue",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
},
|
||||
firstMile: {
|
||||
@@ -2406,9 +2415,11 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
|
||||
// eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so
|
||||
// filing is not a Finance job function — the endpoints exist for controlled testing and
|
||||
// exceptional operations, and are assigned to named admins rather than a role preset.
|
||||
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
|
||||
// (the cron sweep runs as the system); these are the *manual* exceptional-operations
|
||||
// endpoints, and stay off the general Finance role. They are granted to the `chief` position
|
||||
// instead — see below — the same maker–checker split already used for shipping-line credit
|
||||
// mark-paid/cancel (Finance raises, chief decides).
|
||||
FREIGHT_PERMS.payments.view,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
// Shipping-line credit ledger is a Finance surface: bill batches into
|
||||
@@ -2519,6 +2530,14 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.governmentExpedite,
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
// Manual MoR EIMS actions and credit/debit memo issuance: kept off the general Finance role
|
||||
// (see that preset's comment) and granted here instead — the chief is already the decision
|
||||
// side of every other sensitive finance action (mark-paid/cancel approval below), and these
|
||||
// are irreversible-at-MoR or receivable-creating in the same way.
|
||||
FREIGHT_PERMS.invoices.eimsCancel,
|
||||
FREIGHT_PERMS.invoices.eimsResolve,
|
||||
FREIGHT_PERMS.invoices.eimsReceiptRegister,
|
||||
FREIGHT_PERMS.invoices.memoIssue,
|
||||
FREIGHT_PERMS.payments.view,
|
||||
// Decision side of the credit-invoice two-step: finance raises
|
||||
// mark-paid/cancel requests, the chief approves or rejects them.
|
||||
|
||||
Reference in New Issue
Block a user