mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
New endpoint: POST invoices/eims/bulk-cancel, body { items: [{invoiceId,
reasonCode, remark?}] }. Same eimsCancel permission as single cancel — a
batch-scale version of the same irreversible-at-MoR action, not a new
capability.
Same local-eligibility doctrine as single cancel: an already-cancelled or
never-registered invoice is refused right here, no HTTP call, before it
gets a seat in the batch. Only genuinely eligible invoices go into the one
/v1/bulkCancel request; every outcome (local refusal or MoR's own
per-IRN result) is reported back independently — one invoice failing
never blocks the rest.
MoR's bulk response mixes success and error shapes in the same array,
disambiguated by Status (capital, error) vs status (lowercase, success)
— matched back to our invoices by IRN. Notably the bulk success shape
carries no cancellationDate at all, unlike single cancel.
Left out of this pass: bulkRegister. It's async (returns only a
conversationId immediately, results arrive via a webhook callback we
don't have yet) and needs manual counter/previousIrn management per
the collection's own docs — a materially different reservation model
than today's single-invoice TX1/TX2 pattern. Scoping that is a
separate, bigger piece of work.
230 lines
10 KiB
TypeScript
230 lines
10 KiB
TypeScript
import { BadRequestException, ConflictException, 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 {
|
|
EimsBulkCancelItemResult,
|
|
EimsBulkCancelRequest,
|
|
EimsBulkCancelResponse,
|
|
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,
|
|
) {}
|
|
|
|
/**
|
|
* Refuses an already-cancelled invoice with a 409, rather than a silent no-op — IRC-N010 in
|
|
* MoR's Master Compliance Checklist requires "an appropriate error or rejection message" for a
|
|
* repeat cancellation, not a quiet success. No HTTP call either way: this is a local check, not
|
|
* a retry against MoR. Also 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) {
|
|
throw new ConflictException({
|
|
code: "EIMS_ALREADY_CANCELLED",
|
|
message: `Invoice ${invoice.invoiceNumber} was already cancelled with EIMS${invoice.eimsCancellationDate ? ` (${invoice.eimsCancellationDate})` : ""}.`,
|
|
});
|
|
}
|
|
if (!invoice.eimsIrn) {
|
|
throw new BadRequestException({
|
|
code: "EIMS_NOT_REGISTERED",
|
|
message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
|
|
});
|
|
}
|
|
return invoice;
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* `POST /v1/bulkCancel` — one MoR call for every eligible invoice in `items`, matching the
|
|
* collection's own shape (an array in, an array of mixed success/error results back).
|
|
*
|
|
* Same local-eligibility doctrine as `cancelInvoiceWithEims`, applied per item before anything
|
|
* goes to MoR: an already-cancelled or never-registered invoice is refused right here (no HTTP
|
|
* call, no seat in the batch) rather than sent and rejected remotely. Only genuinely eligible
|
|
* invoices are batched into the one `/v1/bulkCancel` request; everything else is reported back
|
|
* immediately.
|
|
*
|
|
* ponytail: the eligibility pass is per-invoice transactions, not one covering the whole batch —
|
|
* same reasoning as the single-cancel path (cancel is idempotent at MoR, so a lock held across
|
|
* every item for the whole call isn't needed for correctness, only for avoiding a wasted call on
|
|
* an item that's already ineligible).
|
|
*/
|
|
async cancelBulkWithEims(
|
|
items: Array<{ invoiceId: string; reasonCode: string; remark?: string }>,
|
|
): Promise<EimsBulkCancelItemResult[]> {
|
|
const results = new Map<string, EimsBulkCancelItemResult>();
|
|
const eligible: Array<{ invoice: Invoice; reasonCode: string; remark?: string }> = [];
|
|
|
|
for (const item of items) {
|
|
try {
|
|
const invoice = await this.dataSource.transaction(async (manager) => {
|
|
const inv = await this.lockInvoice(manager, item.invoiceId);
|
|
if (inv.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
|
throw new ConflictException(
|
|
`Invoice ${inv.invoiceNumber} was already cancelled with EIMS${inv.eimsCancellationDate ? ` (${inv.eimsCancellationDate})` : ""}.`,
|
|
);
|
|
}
|
|
if (!inv.eimsIrn) {
|
|
throw new BadRequestException(
|
|
`Invoice ${inv.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
|
|
);
|
|
}
|
|
return inv;
|
|
});
|
|
eligible.push({ invoice, reasonCode: item.reasonCode, remark: item.remark });
|
|
} catch (err) {
|
|
results.set(item.invoiceId, {
|
|
invoiceId: item.invoiceId,
|
|
success: false,
|
|
message: (err as Error).message,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (eligible.length > 0) {
|
|
const request: EimsBulkCancelRequest = eligible.map((e) => ({
|
|
Irn: e.invoice.eimsIrn!,
|
|
ReasonCode: e.reasonCode,
|
|
Remark: e.remark ?? "",
|
|
}));
|
|
// Outside any transaction — no DB lock held across the wire, same as single cancel.
|
|
const response = await this.client.postBearer<EimsBulkCancelRequest, EimsBulkCancelResponse>(
|
|
"/v1/bulkCancel",
|
|
request,
|
|
);
|
|
const byIrn = new Map((response?.body ?? []).map((entry) => [entry.Irn, entry]));
|
|
|
|
for (const { invoice, reasonCode, remark } of eligible) {
|
|
const entry = byIrn.get(invoice.eimsIrn!);
|
|
const failed = !entry || "Status" in entry;
|
|
if (failed) {
|
|
const message = entry && "msg" in entry ? entry.msg : "EIMS bulk cancel returned no result for this invoice.";
|
|
this.logger.warn(`Invoice ${invoice.invoiceNumber} bulk cancel failed: ${message}`);
|
|
results.set(invoice.id, { invoiceId: invoice.id, success: false, message });
|
|
continue;
|
|
}
|
|
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const fresh = await this.lockInvoice(manager, invoice.id);
|
|
// Re-checked under lock: a concurrent call may have already recorded this cancellation.
|
|
if (fresh.eimsStatus === EimsInvoiceStatus.Cancelled) return;
|
|
await manager.update(Invoice, invoice.id, {
|
|
eimsStatus: EimsInvoiceStatus.Cancelled,
|
|
eimsCancelledAt: new Date(),
|
|
// The bulk success shape carries no cancellationDate at all, unlike single cancel.
|
|
eimsCancellationDate: null,
|
|
eimsCancellationReasonCode: reasonCode,
|
|
eimsCancellationRemark: remark ?? null,
|
|
});
|
|
});
|
|
this.logger.log(`Invoice ${invoice.invoiceNumber} cancelled with EIMS via bulk (IRN ${invoice.eimsIrn})`);
|
|
await this.notifyBuyer(invoice);
|
|
results.set(invoice.id, {
|
|
invoiceId: invoice.id,
|
|
success: true,
|
|
message: `Invoice ${invoice.invoiceNumber} cancelled with EIMS.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return items.map((item) => results.get(item.invoiceId)!);
|
|
}
|
|
|
|
async getEimsCancellationStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
|
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
|
return toEimsInvoiceStatusView(invoice);
|
|
}
|
|
|
|
/** Best-effort — a notification failure must never mask a cancellation that already succeeded. */
|
|
private async notifyBuyer(invoice: Invoice): Promise<void> {
|
|
if (!invoice.companyId) return;
|
|
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;
|
|
}
|
|
}
|