diff --git a/apps/edr-freight-api/.q.mjs b/apps/edr-freight-api/.q.mjs new file mode 100644 index 000000000..b2b454075 --- /dev/null +++ b/apps/edr-freight-api/.q.mjs @@ -0,0 +1,9 @@ +import pg from 'pg'; +import fs from 'fs'; +const env = Object.fromEntries(fs.readFileSync('.env','utf8').split('\n').filter(l=>/^[A-Z_]+=/.test(l)).map(l=>{const i=l.indexOf('=');return [l.slice(0,i),l.slice(i+1).replace(/^"|"$/g,'')]})); +const c = new pg.Client({host:env.DB_HOST,port:+env.DB_PORT,database:env.DB_NAME,user:env.DB_USER,password:env.DB_PASSWORD}); +await c.connect(); +const sql = process.argv[2]; +const r = await c.query(sql); +console.log(JSON.stringify(r.rows,null,1)); +await c.end(); diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts new file mode 100644 index 000000000..a6aa3895d --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -0,0 +1,72 @@ +import eimsConfigFactory from "./eims.config"; + +const REQUIRED = { + EIMS_ENABLED: "true", + EIMS_CLIENT_ID: "cid", + EIMS_CLIENT_SECRET: "secret", + EIMS_API_KEY: "apikey", + EIMS_TIN: "0000000000", +}; + +const withEnv = (vars: Record, fn: () => void) => { + const prior: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prior[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + fn(); + } finally { + for (const [key, value] of Object.entries(prior)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +describe("eims.config — private key / certificate resolution", () => { + it("unescapes a literal \\n when the PEM was pasted without real newlines", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("leaves a PEM with real newlines untouched", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("throws naming all three key/cert options when none are set", () => { + withEnv( + { + ...REQUIRED, + EIMS_PRIVATE_KEY_PATH: undefined, + EIMS_PRIVATE_KEY_BASE64: undefined, + EIMS_PRIVATE_KEY: undefined, + EIMS_CERTIFICATE_PATH: "/dev/null", + }, + () => { + expect(() => eimsConfigFactory()).toThrow( + /EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/, + ); + }, + ); + }); + + it("is satisfied by any single one of the three key options", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(() => eimsConfigFactory()).not.toThrow(); + }, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index cf77072e0..9d99ae455 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -30,6 +30,23 @@ export interface EimsConfig { privateKeyPath: string; /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ certificatePath: string; + /** + * Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a + * container that can't be given a host bind mount can still receive it as a plain env var. + * Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64` + * > `privateKeyPath`. + */ + privateKeyBase64: string; + /** Inline alternative to `certificatePath`, same precedence rule as the key. */ + certificateBase64: string; + /** + * The PEM key pasted directly into the env var, no encoding step at all — the most direct of the + * three inline forms, and the hardest for a broken transport step to mangle since there's no + * decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set. + */ + privateKeyPem: string; + /** Inline alternative to `certificateBase64`, same precedence rule. */ + certificatePem: string; httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; @@ -80,7 +97,19 @@ export interface EimsInvoiceConfig { paymentMode: string; paymentTerm: string; unitDefault: string; + /** + * Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the + * column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign + * buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never + * applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia. + */ buyerCountryCode: string | null; + /** + * Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format + * unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them — + * this is not validated against a fixed digit pattern, only looked up by name. + */ + buyerCountryCodes: Record; /** * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails @@ -89,6 +118,14 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has + * no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike + * Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already + * succeeds with it null), so an unmapped zone falls back to null rather than failing the + * mapping. + */ + buyerCityCodes: Record; /** * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to @@ -115,14 +152,14 @@ export interface EimsInvoiceConfig { buyerIdNumber: string | null; } -const REQUIRED_VARS = [ - "EIMS_CLIENT_ID", - "EIMS_CLIENT_SECRET", - "EIMS_API_KEY", - "EIMS_TIN", - "EIMS_PRIVATE_KEY_PATH", - "EIMS_CERTIFICATE_PATH", -] as const; +const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const; + +// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately +// from REQUIRED_VARS since it's "at least one of", not "this exact var". +const REQUIRED_ANY_OF: string[][] = [ + ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"], + ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"], +]; const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { if (raw === undefined || raw === "") return fallback; @@ -143,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record => { return map; }; +// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline +// and expect the caller to write "\n" as two characters instead. If the raw value already has a +// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses. +const normalizePem = (raw: string | undefined): string => { + if (!raw) return ""; + return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n"); +}; + /** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ const optionalNumber = (raw: string | undefined, name: string): number | null => { if (raw === undefined || raw === "") return null; @@ -169,6 +214,10 @@ export default registerAs("eims", (): EimsConfig => { systemType: process.env.EIMS_SYSTEM_TYPE ?? "", privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", + privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "", + privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY), + certificatePem: normalizePem(process.env.EIMS_CERTIFICATE), + certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "", httpTimeoutMs, tokenSkewMs, autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", @@ -208,8 +257,10 @@ export default registerAs("eims", (): EimsConfig => { paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, + buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES), taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), @@ -223,7 +274,10 @@ export default registerAs("eims", (): EimsConfig => { if (!enabled) return base; - const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]); + for (const vars of REQUIRED_ANY_OF) { + if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or ")); + } if (missing.length > 0) { throw new Error( `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, diff --git a/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts b/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts new file mode 100644 index 000000000..cf36df757 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Debit/credit note filing — confirmed directly by MoR support: same `/v1/register` endpoint, + * distinguished by `DocumentDetails.Type` ("DEB"/"CRE") + a `Reason`, linked to the original + * invoice via `ReferenceDetails.RelatedDocument`. See `Invoice.eimsDocumentType`. + */ +export class EimsDebitCreditNotes3550000000000 implements MigrationInterface { + name = "EimsDebitCreditNotes3550000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_document_type varchar(8) NOT NULL DEFAULT 'INV', + ADD COLUMN IF NOT EXISTS eims_reason text, + ADD COLUMN IF NOT EXISTS related_invoice_id uuid REFERENCES freight.invoices(id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_document_type, + DROP COLUMN IF EXISTS eims_reason, + DROP COLUMN IF EXISTS related_invoice_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts index b00fd588e..0a7ed942e 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { InjectRepository } from "@nestjs/typeorm"; -import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Repository } from "typeorm"; import { ExternalProfile } from "../companies/entities/external-profile.entity"; @@ -11,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto"; import { ForgotPasswordService, RESET_LINK_TTL_MS, + type ResetTicket, } from "./forgot-password.service"; import { maskOtpTarget } from "./mask-target.util"; -import { isDomesticPhone } from "../otp/otp.service"; +import { isDomesticPhone, type OtpTarget } from "../otp/otp.service"; /** The account a staff-triggered reset would land on. */ export interface CustomerResetTarget { @@ -116,6 +116,22 @@ export class CustomerResetService { channel: ResetChannel, options?: { scope?: string; allowWithoutCredential?: boolean }, ): Promise { + const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options); + return sent[0] ?? null; + } + + /** + * One ticket, several channels. Minting retires every earlier ticket for the + * user (`mintResetTicket`), so sending email and SMS as two separate mints + * makes the first link dead on arrival — the same link must go to both. + * Returns one entry per channel that was actually sent (unreachable channels + * are skipped, not errors). + */ + async sendResetLinkToUserOnChannels( + userId: string, + channels: ResetChannel[], + options?: { scope?: string; allowWithoutCredential?: boolean }, + ): Promise { const user = options?.allowWithoutCredential ? await this.forgotPasswordService.resolveActivatableUserById(userId) : await this.forgotPasswordService.resolveActiveUserById(userId); @@ -128,51 +144,58 @@ export class CustomerResetService { : " (or has no active credential — pass allowWithoutCredential for first-time activation)" }`, ); - return null; + return []; } - return this.deliverResetLink(user, user.id, channel, options?.scope); + // Mint once, before any send: a failed send leaves an unused ticket that + // simply expires, whereas sending a link before the ticket exists would + // hand the customer a URL that is dead on arrival. + let ticket: ResetTicket | null = null; + const sent: SentResetLink[] = []; + for (const channel of channels) { + const target = this.forgotPasswordService.targetFor(user, channel); + if (!target) continue; + // The gateway silently drops foreign numbers — treat like a missing phone + // rather than reporting "link sent" for a message that will never arrive. + // The backoffice disables the channel up front via `phoneIsDomestic`; this + // guards direct API calls. + if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) { + this.logger.warn( + `Staff reset via SMS refused for user ${userId} — non-domestic phone`, + ); + continue; + } + ticket ??= await this.forgotPasswordService.mintResetTicket( + user.id, + RESET_LINK_TTL_MS, + ); + const result = await this.deliverResetLink( + target, + user.id, + channel, + ticket, + options?.scope, + ); + if (result) sent.push(result); + } + return sent; } /** - * Shared tail: target selection → SMS reachability → mint → send → report. - * Callers have already resolved `user` to an active account. + * Shared tail: send the already-minted ticket to a resolved target → report. */ private async deliverResetLink( - user: User, + target: OtpTarget, userId: string, channel: ResetChannel, + ticket: ResetTicket, scope?: string, ): Promise { - this.logger.log( - `Staff-triggered shipping line ${"link"}`, - ); - const target = this.forgotPasswordService.targetFor(user, channel); - if (!target) return null; // A foreign number is unreachable by the domestic-only SMS gateway — treat // it like a missing phone rather than reporting "link sent" for a message - // that will never arrive. The backoffice disables the channel up front via - // `phoneIsDomestic`; this guards direct API calls. - if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) { - this.logger.warn( - `Staff reset via SMS refused for user ${userId} — non-domestic phone`, - ); - return null; - } - - // Mint first, send second: a failed send leaves an unused ticket that simply - // expires, whereas sending a link before the ticket exists would hand the - // customer a URL that is dead on arrival. - const ticket = await this.forgotPasswordService.mintResetTicket( - userId, - RESET_LINK_TTL_MS, - ); const link = this.buildResetLink(ticket.userId, ticket.verificationCode); const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); -this.logger.log( - `Staff-triggered shipping line ${link}`, - ); const { queued } = target.email ? await this.emailClient.sendEmail({ diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index a2afdbbbc..673541747 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -212,7 +212,9 @@ export class ForgotPasswordService { * is the proof). */ async mintResetTicket(userId: string, ttlMs: number): Promise { - const code = randomBytes(24).toString("base64url"); + // Hex, not base64url: the token rides in an SMS, and the GSM-7 alphabet has + // no "_" — gateways substitute a space and the link arrives broken. + const code = randomBytes(24).toString("hex"); const verificationCode = await hashPassword(code); await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5bf1448fd..810c1a55a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Get, @@ -29,6 +30,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 +40,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 { @@ -63,6 +66,23 @@ export class BillingController { }); } + @Get("invoices/summary") + @ApiOperation({ + summary: + "Total collected (paidAmount) across every filtered invoice, grouped by currency", + }) + async collectedSummary( + @Query() query: FilterInvoiceDto, + @CurrentUser() user: TCurrentUser, + ) { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.billingService.collectedSummary({ + ...query, + tradeDirections: allowed ?? undefined, + }); + } + @Get("invoices/:id") @ApiOperation({ summary: "Get an invoice with its line items" }) findById(@Param("id", ParseUUIDPipe) id: string) { @@ -72,7 +92,7 @@ export class BillingController { @Get("offline-usd") @ApiOperation({ summary: - "Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context", + "Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context", }) findOfflineUsd(@Query() query: FilterInvoiceDto) { return this.billingService.findOfflineUsdPaginated(query); @@ -84,7 +104,7 @@ export class BillingController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - "Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", + "Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance", }) confirmOffline( @Param("id", ParseUUIDPipe) id: string, @@ -99,11 +119,31 @@ 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" }) - async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { - const { filename, buffer } = await this.billingService.document(id); + @ApiOperation({ + summary: + 'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).', + }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @Query("format") format: string | undefined, + @Res() res: Response, + ) { + if (format !== undefined && format !== "a4" && format !== "thermal") { + throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`); + } + const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4"); sendPdf(res, filename, buffer); } diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index d9eef5a43..243b1ee4d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -119,6 +119,159 @@ describe("BillingService.generateInvoice", () => { }); }); +describe("BillingService.issueMemo", () => { + const ORIGINAL_ID = "original-invoice-1"; + + function originalInvoice(overrides: Record = {}) { + 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) { + 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).eimsDocumentType).toBe("CRE"); + expect((memo as unknown as Record).eimsReason).toBe("Overbilled freight charge"); + expect((memo as unknown as Record).relatedInvoiceId).toBe(ORIGINAL_ID); + expect((memo as unknown as Record).paidAmount).toBe(1500); + expect((memo as unknown as Record).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 = { @@ -774,6 +927,7 @@ describe("BillingService.document", () => { const build = (invoice: Record) => { const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); + const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") }); const service = new BillingService( {} as never, { findById: jest.fn().mockResolvedValue(invoice) } as never, @@ -781,7 +935,7 @@ describe("BillingService.document", () => { {} as never, {} as never, {} as never, - { render } as never, + { render, renderThermal } as never, {} as never, { get: (key: string) => @@ -790,7 +944,7 @@ describe("BillingService.document", () => { : undefined, } as never, // config ); - return { service, render }; + return { service, render, renderThermal }; }; it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { @@ -847,4 +1001,24 @@ describe("BillingService.document", () => { expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); }); + + it("calls render (not renderThermal) for the default format", async () => { + const { service, render, renderThermal } = build(invoiceRow()); + jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); + + await service.document("inv-1"); + + expect(render).toHaveBeenCalledTimes(1); + expect(renderThermal).not.toHaveBeenCalled(); + }); + + it("calls renderThermal (not render) for format 'thermal'", async () => { + const { service, render, renderThermal } = build(invoiceRow()); + jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); + + await service.document("inv-1", "thermal"); + + expect(renderThermal).toHaveBeenCalledTimes(1); + expect(render).not.toHaveBeenCalled(); + }); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 5afa30128..44e194a8d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -10,14 +10,16 @@ import { } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { logCtx } from "@edr/api-common"; -import { DataSource, EntityManager, In } from "typeorm"; +import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; // Entity-only import (no module edge): portal reads resolve shipping-line // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; +import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.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 +27,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"; @@ -46,10 +49,18 @@ export interface PayInvoiceOptions { export interface OfflineUsdBookingInfo { id: string; reference: string; + tradeDirection: string | null; paymentDeadline: Date | null; paymentStatus: string; } +/** Row shape of the manual-payments worklist. */ +export type OfflineUsdInvoiceRow = Invoice & { + booking: OfflineUsdBookingInfo | null; + /** Shipping-line credit invoices span many bookings — one entry per credit. */ + bookings: { id: string; reference: string; tradeDirection: string | null }[]; +}; + /** A single manual/offline settlement to record against an invoice. */ export interface RecordPaymentInput { /** Amount settled by this payment; must be > 0. */ @@ -145,6 +156,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.`. */ export interface InvoiceEventPayload { invoiceId: string; @@ -192,6 +215,40 @@ export class BillingService { * company (customer detail "Invoices" tab) and/or status/search (global * invoices page). */ + /** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */ + private applyInvoiceFilters( + qb: SelectQueryBuilder, + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + tradeDirections?: string[]; + }, + ) { + if (filter.companyId) { + qb.andWhere("invoice.companyId = :companyId", { + companyId: filter.companyId, + }); + } + if (filter.status) { + qb.andWhere("invoice.status = :status", { status: filter.status }); + } + if (filter.search) { + qb.andWhere( + "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + { search: `%${filter.search}%` }, + ); + } + if (filter.tradeDirections) { + applyBookingRefDirectionScope( + qb, + "invoice.source_id", + filter.tradeDirections, + ); + } + return qb; + } + async findAllPaginated( filter: { companyId?: string; @@ -215,50 +272,100 @@ export class BillingService { .skip((page - 1) * pageSize) .take(pageSize); - if (filter.companyId) { - qb.andWhere("invoice.companyId = :companyId", { - companyId: filter.companyId, - }); - } - if (filter.status) { - qb.andWhere("invoice.status = :status", { status: filter.status }); - } - if (filter.search) { - qb.andWhere( - "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", - { search: `%${filter.search}%` }, - ); - } - - if (filter.tradeDirections) { - applyBookingRefDirectionScope( - qb, - "invoice.source_id", - filter.tradeDirections, - ); - } + this.applyInvoiceFilters(qb, filter); const [items, total] = await qb.getManyAndCount(); - return { items, total }; + return { items: await this.attachShippingLineCompanies(items), total }; } /** - * Finance's offline-settlement worklist: USD invoices (paid by bank transfer, - * never through the gateway), open ones by default or a single status when - * filtered. Booking-sourced rows carry the booking's reference and pay-window - * deadline so the UI can show the countdown and link to the booking. + * Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping + * line (`companyId` null). No relation on `Invoice` to eager-load — see the + * entity's doc comment — so this is a second query keyed off the ids + * already loaded, same shape as `company`. + */ + private async attachShippingLineCompanies( + invoices: T[], + ): Promise { + const ids = [ + ...new Set( + invoices + .map((i) => i.shippingLineCompanyId) + .filter((id): id is string => id != null), + ), + ]; + if (!ids.length) return invoices; + const lines = await this.dataSource + .getRepository(ShippingLineCompany) + .find({ where: { id: In(ids) } }); + const byId = new Map(lines.map((l) => [l.id, l])); + return invoices.map((invoice) => { + const line = invoice.shippingLineCompanyId + ? byId.get(invoice.shippingLineCompanyId) + : undefined; + return line + ? ({ + ...invoice, + shippingLineCompany: { + id: line.id, + name: line.name, + email: line.email, + phoneNumber: line.phoneNumber, + }, + } as T) + : invoice; + }); + } + + /** + * Total collected (`paidAmount`) across every invoice matching the same + * filters as `findAllPaginated`, grouped by currency — unpaginated, so the + * invoices summary card reflects the whole filtered set, not just the + * visible page. + */ + async collectedSummary( + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + tradeDirections?: string[]; + } = {}, + ): Promise> { + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .select("invoice.currency", "currency") + .addSelect("SUM(invoice.paidAmount)", "collected") + .groupBy("invoice.currency"); + + this.applyInvoiceFilters(qb, filter); + + const rows: { currency: string; collected: string }[] = + await qb.getRawMany(); + + return Object.fromEntries( + rows.map((row) => [row.currency, Number(row.collected) || 0]), + ); + } + + /** + * Finance's manual-settlement worklist: USD invoices (paid by bank transfer, + * never through the gateway) and ETB invoices Finance settles by hand (bank + * transfer / counter) instead of the customer paying online. Open ones by + * default or a single status when filtered; both currencies unless + * `currency` narrows it. Booking-sourced rows carry the booking's reference, + * trade direction and pay-window deadline so the UI can show the countdown + * and link to the booking. */ async findOfflineUsdPaginated( filter: { status?: Freight.InvoiceStatus; search?: string; + currency?: "USD" | "ETB"; page?: number; pageSize?: number; } = {}, - ): Promise<{ - items: (Invoice & { booking: OfflineUsdBookingInfo | null })[]; - total: number; - }> { + ): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; @@ -267,11 +374,16 @@ export class BillingService { .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") - .where("UPPER(invoice.currency) = 'USD'") + .where("UPPER(invoice.currency) IN ('USD', 'ETB')") .orderBy("invoice.issuedAt", "DESC") .skip((page - 1) * pageSize) .take(pageSize); + if (filter.currency) { + qb.andWhere("UPPER(invoice.currency) = :currency", { + currency: filter.currency, + }); + } if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } else { @@ -284,7 +396,8 @@ export class BillingService { ); } - const [items, total] = await qb.getManyAndCount(); + const [rawItems, total] = await qb.getManyAndCount(); + const items = await this.attachShippingLineCompanies(rawItems); const bookingIds = items .filter((i) => i.source === "booking") @@ -292,11 +405,43 @@ export class BillingService { const bookings = bookingIds.length ? await this.dataSource.getRepository(Booking).find({ where: { id: In(bookingIds) }, - select: ["id", "reference", "paymentDeadline", "paymentStatus"], + select: [ + "id", + "reference", + "tradeDirection", + "paymentDeadline", + "paymentStatus", + ], }) : []; const byId = new Map(bookings.map((b) => [b.id, b])); + // Shipping-line credit invoices bill many bookings at once; each credit + // keeps its own booking link, so collect them per invoice. + const creditInvoiceIds = items + .filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit) + .map((i) => i.id); + const credits = creditInvoiceIds.length + ? await this.dataSource.getRepository(ShippingLineCredit).find({ + where: { invoiceId: In(creditInvoiceIds) }, + relations: { booking: true }, + }) + : []; + const bookingsByInvoice = new Map< + string, + OfflineUsdInvoiceRow["bookings"] + >(); + for (const c of credits) { + if (!c.invoiceId || !c.booking) continue; + const list = bookingsByInvoice.get(c.invoiceId) ?? []; + list.push({ + id: c.booking.id, + reference: c.booking.reference, + tradeDirection: c.booking.tradeDirection ?? null, + }); + bookingsByInvoice.set(c.invoiceId, list); + } + return { items: items.map((inv) => { const b = byId.get(inv.sourceId); @@ -306,19 +451,22 @@ export class BillingService { ? { id: b.id, reference: b.reference, + tradeDirection: b.tradeDirection ?? null, paymentDeadline: b.paymentDeadline ?? null, paymentStatus: b.paymentStatus, } : null, - } as Invoice & { booking: OfflineUsdBookingInfo | null }; + bookings: bookingsByInvoice.get(inv.id) ?? [], + } as OfflineUsdInvoiceRow; }), total, }; } /** - * Finance confirms a USD invoice as paid by bank transfer: stores the slip - * against the invoice and settles the FULL outstanding balance through + * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer + * or counter payment: stores the slip against the invoice and settles the + * FULL outstanding balance through * {@link recordPayment}, which flips the invoice to PAID and (for bookings) * emits `booking.invoice.paid` — the same event an online payment fires, so * the booking advances exactly as if it had been paid through the gateway. @@ -337,11 +485,6 @@ export class BillingService { ): Promise { const invoice = await this.invoices.findById(invoiceId); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); - if (invoice.currency?.toUpperCase() !== "USD") { - throw new BadRequestException( - "Offline confirmation is only for USD invoices — this invoice is paid online.", - ); - } if (!file) { throw new BadRequestException("The bank payment slip file is required."); } @@ -388,21 +531,30 @@ export class BillingService { relations: { company: true, companyProfile: true }, }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const [hydrated] = await this.attachShippingLineCompanies([invoice]); const lines = await this.invoiceLines.findAll({ where: { invoiceId: id }, order: { createdAt: "ASC" }, }); - return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; + return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] }; } // ── Documents (central PDF) ────────────────────────────────────────────────── - /** Sealed PDF invoice for any source, rendered by the shared document service. */ - async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Sealed PDF invoice for any source, rendered by the shared document service. `format` + * validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input + * boundary check, not a business rule. + */ + async document( + id: string, + format: "a4" | "thermal" = "a4", + ): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render( - await this.toDocumentModel(invoice, "INVOICE"), - ); + const model = await this.toDocumentModel(invoice, "INVOICE"); + return format === "thermal" + ? this.invoiceDocuments.renderThermal(model) + : this.invoiceDocuments.render(model); } /** Sealed PDF receipt; available once any payment has been recorded. */ @@ -418,15 +570,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 +685,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 +852,16 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── - /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ - private nextInvoiceNumber(mg: EntityManager): Promise { + /** + * `-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 { return nextDailyInvoiceNumber(mg, { table: "freight.invoices", - code: "INV", + code, }); } @@ -736,9 +884,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 { + 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 = { + 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 { const currency = input.currency ?? "ETB"; const status = input.status ?? Freight.InvoiceStatus.Pending; @@ -786,7 +1048,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, { diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index bc5250888..ebdf51be1 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -44,3 +44,46 @@ describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { ); }); }); + +describe("InvoiceDocumentService.buildThermalHtml", () => { + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); + + it("renders no seal markup at all — dropped for thermal, not shrunk", () => { + const html = service.buildThermalHtml(model()); + expect(html).not.toContain('class="seal"'); + expect(html).not.toContain("seal-image"); + }); + + it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => { + const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" })); + expect(html).toContain('class="qr"'); + expect(html).toContain('src="data:image/png;base64,QR"'); + expect(html).not.toContain("position: absolute"); + }); + + it("wraps a long IRN summary value rather than truncating it", () => { + const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; + const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] })); + expect(html).toContain(irn); + expect(html).toContain("overflow-wrap: anywhere"); + }); + + it("renders a line item as stacked description + qty x rate = amount, not a table row", () => { + const html = service.buildThermalHtml( + model({ + lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }], + }), + ); + expect(html).not.toContain(" { + const html = service.buildThermalHtml(model()); + expect(html).not.toContain("width: 330px"); + expect(html).not.toContain("right: 160px"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index f6b264636..0f8e4ee71 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -18,6 +18,36 @@ import { 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}`; + +// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ────────────────────────────────── +// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already- +// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here. + +function esc(value: unknown): string { + return String(value ?? "-") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function money(amount: unknown, currency: string): string { + return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; +} + +function formatDate(value: unknown): string { + return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; +} + /** One billed line on the document (charge type / fee type agnostic). */ export interface InvoiceDocumentLine { description: string | null; @@ -120,6 +150,117 @@ export class InvoiceDocumentService { }; } + /** + * 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within + * `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since + * thermal print mechanisms have a dead zone at the roll edge they can't reach either way. + * + * A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is + * absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`, + * `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width. + * No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS + * thermal receipt carries one, and thermal heads render rotated circles badly) and line items + * are stacked (description, then `qty x rate = amount` below it) rather than a table — a real + * multi-column table leaves ~10-14 chars for description at this width, truncating almost every + * line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`. + */ + async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> { + const logoImageUrl = + model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl(); + // Seal deliberately dropped — never fetched, so no stampSettings call either. + const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null }; + + const html = this.buildThermalHtml(resolvedModel); + return { + filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`, + buffer: await this.pdf.htmlToPdfBuffer(html, { + label: `${model.title} thermal invoice`, + thermal: true, + // A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal + // printer output" — fail loudly instead; the caller has the A4 download to fall back to. + noFallback: true, + }), + }; + } + + buildThermalHtml(model: InvoiceDocumentModel): string { + const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`; + const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo"); + + const summaryRows = model.summary + .map( + (row) => + `
${esc(row.label)}${esc(row.value)}
`, + ) + .join(""); + + const itemBlocks = model.lines + .map((item) => { + const currency = item.currency ?? model.currency; + return `
+
${esc(item.description)}
+
${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = ${esc(money(item.amount, currency))}
+
`; + }) + .join(""); + + const totalRows = model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, + ) + .join(""); + + const qrMarkup = model.qrImageUrl + ? `
EIMS verification QR
Scan to verify (MoR EIMS)
` + : ""; + + return ` + + + + ${esc(heading)} + + + +
+ ${logoInner} +
Ethio-Djibouti Railway S.C.
+
${esc(heading)}
+
${esc(model.documentNumber)} · ${esc(formatDate(model.issuedAt))}
+
+ ${summaryRows} +
+ ${itemBlocks} +
+ ${totalRows} + ${qrMarkup} + +
+ +`; + } + /** * Vector-drawn styled invoice/receipt used when headless Chromium is * unavailable. Mirrors the HTML layout closely enough to pass as the same @@ -241,18 +382,7 @@ export class InvoiceDocumentService { } buildHtml(model: InvoiceDocumentModel): string { - const esc = (value: unknown) => - String(value ?? "-") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - const money = (amount: unknown, currency = model.currency) => - `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; - const date = (value: unknown) => - value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; - + const date = formatDate; const showCategory = Boolean(model.categoryHeader); const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); @@ -283,7 +413,7 @@ export class InvoiceDocumentService { const totalRows = model.totals .map( (total) => - `
${esc(total.label)}${esc(money(total.amount))}
`, + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, ) .join(""); diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index e8c3de792..3767637a7 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = ` } `; +/** + * Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream + * ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll + * it physically can't reach, so the page itself must stay 80mm (matching the roll the printer + * driver expects) with the safe area carved out by margin, not by shrinking the page. + */ +const THERMAL_PAGE_WIDTH_MM = 80; +const THERMAL_MARGIN_MM = 4; +/** Extra length past the measured content, so the cut isn't flush against the last line. */ +const THERMAL_FEED_MM = 6; +/** Guard against a runaway line-item list producing an absurd page. */ +const THERMAL_MAX_HEIGHT_MM = 1500; + export interface PdfRenderOptions { /** Label used in logs to identify the document kind. */ label?: string; /** Landscape A4 instead of the default portrait — wide tables need it. */ landscape?: boolean; + /** + * Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is + * measured and the page height grows to fit it, rather than a fixed page with the format's + * `format: "A4"`. + */ + thermal?: boolean; + /** + * Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a + * generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer + * output" (it silently hands back a different document shape than what was asked for); the + * caller has an existing A4 download to point the user at instead. Ignored when `fallback` is + * also supplied — an explicit fallback always wins. + */ + noFallback?: boolean; /** * Degraded renderer used when Chromium is unavailable. Receives the * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` - * header). When omitted, a generic single-page fallback is produced. + * header). When omitted (and `noFallback` is not set), a generic single-page fallback is + * produced. */ fallback?: (preparedHtml: string) => Buffer; } @@ -54,17 +82,37 @@ export class PdfRenderService { const browser = await puppeteer.default.launch(launchOptions); try { const page = await browser.newPage(); - await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + const thermal = opts.thermal ?? false; + const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794; + // Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight + // is defined as the LARGER of the content's height and the viewport's own height, so a + // receipt shorter than the viewport would otherwise report the viewport height back, not + // its true content height — a real page-length trailing blank space bug, not theoretical + // (confirmed by actually rendering one). A short viewport forces content to overflow it, + // so scrollHeight always reflects the content, never the viewport. + await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 }); await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); await page.emulateMediaType("print"); await new Promise((resolve) => setTimeout(resolve, 250)); - const pdf = await page.pdf({ - format: "A4", - landscape: opts.landscape ?? false, - printBackground: true, - margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, - }); + const pdf = thermal + ? await page.pdf({ + width: `${THERMAL_PAGE_WIDTH_MM}mm`, + height: `${await this.thermalContentHeightMm(page)}mm`, + printBackground: true, + margin: { + top: `${THERMAL_MARGIN_MM}mm`, + bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`, + left: `${THERMAL_MARGIN_MM}mm`, + right: `${THERMAL_MARGIN_MM}mm`, + }, + }) + : await page.pdf({ + format: "A4", + landscape: opts.landscape ?? false, + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); const buffer = Buffer.from(pdf); if (!this.isValidPdf(buffer)) { @@ -79,6 +127,15 @@ export class PdfRenderService { } } catch (error) { this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + if (!opts.fallback && opts.noFallback) { + // A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal + // printer output" — it silently hands back a different document than what was asked for. + // Fail loudly instead; the caller already has a working A4 download to fall back to. + throw new InternalServerErrorException( + `${label} could not be generated — thermal rendering requires Chromium. ` + + "Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.", + ); + } const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); if (this.isValidPdf(fallback)) { this.logger.warn( @@ -92,6 +149,20 @@ export class PdfRenderService { } } + /** + * Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered + * content's actual height and adds feed clearance, so the PDF page is exactly as long as the + * receipt, not a fixed A4-length page with blank space at the bottom. + */ + private async thermalContentHeightMm(page: import("puppeteer").Page): Promise { + // String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document` + // isn't a known global to type-check against — the string is evaluated in the page's own + // browser context regardless, same as the closure form would be. + const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number; + const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM; + return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100); + } + private injectPdfPrintStyles(html: string): string { if (html.includes("edr-pdf-print-fix")) return html; if (html.includes("")) { diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index e8942d586..91327946c 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -39,4 +39,11 @@ export class FilterInvoiceDto { @IsOptional() @IsIn(Object.values(Freight.InvoiceStatus)) status?: Freight.InvoiceStatus; + + /** Manual-payments worklist only: restrict to one currency. */ + @ApiPropertyOptional({ enum: ["USD", "ETB"] }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) + @IsIn(["USD", "ETB"]) + currency?: "USD" | "ETB"; } diff --git a/apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts new file mode 100644 index 000000000..c80657155 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts @@ -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; +} + +/** `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[]; +} diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 927e2efec..6d58a7bec 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -60,8 +60,11 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, + buyerCountryCode: "231", // test-only, not a confirmed real MoR code + buyerCountryCodes: {}, buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: {}, + buyerCityCodes: {}, ...over, }); @@ -92,6 +95,9 @@ describe("toEimsInvoice", () => { expect(doc.BuyerDetails).toEqual({ City: null, + // company.country is "Ethiopia" (the domestic default) — resolves to context's flat + // buyerCountryCode fallback, not null, per resolveCountryCode. + Country: "231", Email: "buyer@abc.et", HouseNumber: "NEW", IdNumber: null, @@ -100,7 +106,6 @@ describe("toEimsInvoice", () => { LegalName: "ABC Trading PLC", Phone: "0912345678", Region: "13", - Country: null, Zone: "SHA", Kebele: "03", VatNumber: "123475885858", @@ -212,6 +217,59 @@ describe("toEimsInvoice", () => { expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/); }); + describe("debit/credit notes — confirmed by MoR support, same /v1/register endpoint", () => { + it("defaults DocumentDetails.Type to INV with no Reason field", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + expect(doc.DocumentDetails.Type).toBe("INV"); + expect(doc.DocumentDetails).not.toHaveProperty("Reason"); + }); + + it("files a credit note with Type, Reason and RelatedDocument", () => { + const doc = toEimsInvoice( + invoice(), + seller, + context({ + documentType: "CRE", + reason: "Overbilled freight charge", + relatedDocument: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + }), + ); + expect(doc.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" }); + expect(doc.ReferenceDetails.RelatedDocument).toBe( + "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + ); + }); + + it("files a debit note the same way", () => { + const doc = toEimsInvoice( + invoice(), + seller, + context({ documentType: "DEB", reason: "Additional handling fee", relatedDocument: "IRN-1" }), + ); + expect(doc.DocumentDetails).toMatchObject({ Type: "DEB", Reason: "Additional handling fee" }); + }); + + it("throws when a credit/debit note has no reason", () => { + expect(() => + toEimsInvoice( + invoice(), + seller, + context({ documentType: "CRE", reason: null, relatedDocument: "IRN-1" }), + ), + ).toThrow(/needs a reason/); + }); + + it("throws when a credit/debit note has no relatedDocument", () => { + expect(() => + toEimsInvoice( + invoice(), + seller, + context({ documentType: "CRE", reason: "Overbilled", relatedDocument: null }), + ), + ).toThrow(/needs.*relatedDocument/); + }); + }); + it("throws when the lines do not sum to the invoice total", () => { expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow( /lines sum to 11000 but the invoice total is 9000/, @@ -282,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => { ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); }); + it("derives City from the buyer's zone via the city code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Kirkos" } }), + seller, + context({ buyerCityCodes: { Kirkos: "101" } }), + ); + expect(doc.BuyerDetails.City).toBe("101"); + }); + + it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }), + seller, + context({ buyerCityCodes: {} }), + ); + expect(doc.BuyerDetails.City).toBeNull(); + }); + + it("maps a buyer country name to its code via the country code map", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Djibouti" } }), + seller, + context({ buyerCountryCodes: { Djibouti: "071" } }), + ); + expect(doc.BuyerDetails.Country).toBe("071"); + }); + + it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Ethiopia" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ); + expect(doc.BuyerDetails.Country).toBe("231"); + }); + + it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, country: "Kenya" } }), + seller, + context({ buyerCountryCode: "231", buyerCountryCodes: {} }), + ), + ).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/); + }); + it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" })); expect(doc.ItemList[0].NatureOfSupplies).toBe("service"); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index fb7f2f145..b8755c709 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -20,8 +20,15 @@ import { round2 } from "./invoice-settlement.util"; /** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ const EIMS_VERSION = "1"; -/** The only `DocumentDetails.Type` observed in the supplied material. */ -const EIMS_DOCUMENT_TYPE = "INV"; +/** + * `DocumentDetails.Type`. `"INV"` is the only value observed in the collection; `"DEB"`/`"CRE"` + * (debit/credit note) were confirmed directly by MoR support — same `/v1/register` endpoint, no + * separate API. MoR's answer, verbatim: "the same endpoint used for registration should be used + * ... within the Document Detail object, you should specify DEB for a debit note, CRE for a + * credit note... add a Reason attribute under document detail object". + */ +export const EIMS_DOCUMENT_TYPES = ["INV", "DEB", "CRE"] as const; +export type EimsDocumentType = (typeof EIMS_DOCUMENT_TYPES)[number]; export interface EimsBuyerDetails { City: string | null; @@ -60,7 +67,9 @@ export interface EimsDocumentDetails { DocumentNumber: string; /** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */ Date: string; - Type: string; + Type: EimsDocumentType; + /** Only for DEB/CRE, per MoR support — why the debit/credit note was issued. Absent for INV. */ + Reason?: string; } export interface EimsInvoiceItem { @@ -212,10 +221,26 @@ export interface EimsMapperContext { unitDefault: string; incomeWithholdValue: number; transactionWithholdValue: number; - /** Null for an ordinary invoice; set only for a real related-document case. */ + /** + * `DocumentDetails.Type`. Defaults to `"INV"`. For `"DEB"`/`"CRE"` both `reason` and + * `relatedDocument` become required — confirmed directly by MoR support, not the collection. + */ + documentType?: EimsDocumentType; + /** Required when `documentType` is `"DEB"`/`"CRE"` — why the note was issued. Unused for INV. */ + reason?: string | null; + /** + * `ReferenceDetails.RelatedDocument`. Null for an ordinary invoice; required for a DEB/CRE — + * the original registered invoice's IRN, per MoR's own IRC-P06/P07 checklist ("credit memo + * from a registered invoice"). + */ relatedDocument?: string | null; - /** MoR numeric country code for the buyer; our DB stores the country name. */ + /** + * Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already + * in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer. + */ buyerCountryCode?: string | null; + /** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */ + buyerCountryCodes: Record; /** * Region name → MoR numeric code, for buyers whose stored region is free text. * @@ -232,9 +257,15 @@ export interface EimsMapperContext { * fail locally on an unmapped name rather than file a guess. */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the + * closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already + * accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail + * the mapping. + */ + buyerCityCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; - buyerCity?: string | null; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; invoiceDiscount?: number | null; @@ -279,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string => * exchange rate. */ /** - * A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric, - * otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending - * a guessed code onto a tax document is worse than refusing to file. + * A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already + * numeric, otherwise looked up by name (case- and space-insensitive). + * + * Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax + * document is worse than refusing to file. City is optional (`required: false`, City's own + * caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to + * null instead of blocking the invoice. */ function resolveLocationCode( - field: "Region" | "Wereda", + field: "Region" | "Wereda" | "City", value: string | null | undefined, codes: Record, envVar: string, invoiceNumber: string, -): string { + opts: { required?: boolean } = {}, +): string | null { const raw = (value ?? "").trim(); if (LOCATION_CODE.test(raw)) return raw; @@ -299,12 +335,61 @@ function resolveLocationCode( )?.[1]; if (mapped && LOCATION_CODE.test(mapped)) return mapped; + if (opts.required === false) return null; + throw new Error( `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, ); } +/** + * A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies + * `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default). + * A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same + * "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's + * Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`. + */ +function resolveCountryCode( + country: string | null | undefined, + codes: Record, + domesticFallback: string | null, + invoiceNumber: string, +): string | null { + const raw = (country ?? "").trim(); + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + if (mapped) return mapped; + + if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback; + + throw new Error( + `EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` + + "MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.", + ); +} + +/** + * Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an + * error to and that must never throw — currently only `EimsSellerCacheService`, resolving + * e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code, + * name lookup, `undefined` on no match — the caller falls back to static config either way. + */ +export function resolveOptionalCode( + value: string | null | undefined, + codes: Record, +): string | undefined { + const raw = (value ?? "").trim(); + if (LOCATION_CODE.test(raw)) return raw; + const key = raw.toLowerCase().replace(/\s+/g, " "); + const mapped = Object.entries(codes).find( + ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, + )?.[1]; + return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined; +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -326,6 +411,26 @@ export function toEimsInvoice( ); } + const documentType = context.documentType ?? "INV"; + if (!EIMS_DOCUMENT_TYPES.includes(documentType)) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} has documentType "${documentType}", must be one of ${EIMS_DOCUMENT_TYPES.join(", ")}`, + ); + } + if (documentType !== "INV") { + if (!context.reason?.trim()) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs a reason`, + ); + } + if (!context.relatedDocument?.trim()) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs ` + + "relatedDocument — the original registered invoice's IRN", + ); + } + } + const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt); if (Number.isNaN(issuedAt.getTime())) { throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); @@ -400,7 +505,16 @@ export function toEimsInvoice( return { BuyerDetails: { - City: context.buyerCity ?? null, + // No dedicated city column on Company — Zone is the closest match; optional (see + // resolveLocationCode's City comment). + City: resolveLocationCode( + "City", + company.zone, + context.buyerCityCodes, + "EIMS_BUYER_CITY_CODES", + invoice.invoiceNumber, + { required: false }, + ), Email: company.email ?? null, HouseNumber: company.houseNo ?? null, IdNumber: context.buyerIdNumber ?? null, @@ -415,7 +529,12 @@ export function toEimsInvoice( "EIMS_BUYER_REGION_CODES", invoice.invoiceNumber, ), - Country: context.buyerCountryCode ?? null, + Country: resolveCountryCode( + company.country, + context.buyerCountryCodes, + context.buyerCountryCode ?? null, + invoice.invoiceNumber, + ), Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, @@ -430,7 +549,8 @@ export function toEimsInvoice( DocumentDetails: { DocumentNumber: context.documentNumber, Date: (context.formatDate ?? formatEimsDate)(issuedAt), - Type: EIMS_DOCUMENT_TYPE, + Type: documentType, + ...(documentType !== "INV" ? { Reason: context.reason! } : {}), }, ItemList, PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term }, diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 7d0ff34c4..43b6e3271 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -180,4 +180,27 @@ export class Invoice extends BaseEntity { @Column({ name: "eims_cancellation_remark", type: "text", nullable: true }) eimsCancellationRemark?: string | null; + + /** + * `DocumentDetails.Type` to file this invoice as — "INV" (default), "DEB" or "CRE". Confirmed + * by MoR support directly (not the collection): debit/credit notes go through this same + * `/v1/register` endpoint, distinguished only by `Type` + `Reason`, linked via + * `ReferenceDetails.RelatedDocument` to the original invoice's IRN. This module does not create + * debit/credit note invoices — that is a freight-workflow decision — it only files one + * correctly once these columns are set on an existing row. + */ + @Column({ name: "eims_document_type", type: "varchar", length: 8, default: "INV" }) + eimsDocumentType!: string; + + /** Required by MoR when `eimsDocumentType` is DEB/CRE — why the note was issued. */ + @Column({ name: "eims_reason", type: "text", nullable: true }) + eimsReason?: string | null; + + /** The original registered invoice this debit/credit note adjusts. Required for DEB/CRE. */ + @Column({ name: "related_invoice_id", type: "uuid", nullable: true }) + relatedInvoiceId?: string | null; + + @ManyToOne(() => Invoice) + @JoinColumn({ name: "related_invoice_id" }) + relatedInvoice?: Invoice | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts new file mode 100644 index 000000000..8babc8c2e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -0,0 +1,42 @@ +import { BadRequestException } from '@nestjs/common'; + +import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; + +/** + * Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking + * cut is allowed and takes the exact cargo total; over-cut is rejected; a + * partial cut stays proportional. + */ +describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { + const svc = Object.create(BookingWagonCancellationService.prototype) as { + resolveRequestedCut(booking: unknown, dto: unknown): Promise<{ + wagons: number; + weightTons: number; + quantities: { bulkTons?: number }; + }>; + }; + const booking = { + id: 'b1', + freightType: 'BULK', + wagonsRequired: 4, + cargoTotalWeightVgm: 250.5, + bulkTotalWeightTons: null, + }; + + it('cancels every wagon with the exact total tonnage', async () => { + const cut = await svc.resolveRequestedCut(booking, { wagons: 4 }); + expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } }); + }); + + it('rejects more wagons than the booking has', async () => { + await expect(svc.resolveRequestedCut(booking, { wagons: 5 })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('sizes a partial cut proportionally', async () => { + const cut = await svc.resolveRequestedCut(booking, { wagons: 1 }); + expect(cut.wagons).toBe(1); + expect(cut.weightTons).toBeCloseTo(62.625, 3); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index b62fdfca0..0b4315d62 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -7,8 +7,9 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; -import { DataSource, EntityManager, In } from 'typeorm'; +import { DataSource, EntityManager, In, IsNull } from 'typeorm'; import { BillingService } from '../billing/billing.service'; import { ContractBookingService } from '../contracts/contract-booking.service'; @@ -18,9 +19,11 @@ import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.en import { FirstMileService } from '../first-mile/first-mile.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; @@ -46,10 +49,15 @@ import { /** * rates.rate_type of the cancellation fee — an existing rate-engine type * (trigger CANCELLATION, never auto-applied to booking pricing). Staff - * configure it in the normal rates UI; the wagon flow requires the PER_WAGON - * unit so the fee scales with the cancelled wagon count. + * configure it in the normal rates UI, one PER_WAGON rate per trade direction + * + cargo kind + type (20ft / 40ft container type, or bulk commodity), so the + * fee scales with the cancelled wagon count and differs by what was booked. */ export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE'; + +/** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */ +const sizeFtOf = (size: string | number | null | undefined): number => + parseInt(String(size ?? ''), 10); /** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */ export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE'; @@ -62,8 +70,20 @@ interface RequestedCut { quantities: CancelledQuantities; } +/** The priced fee for a cut: total, currency and the rate(s) it came from. */ +interface PricedFee { + amount: number; + currency: string; + /** Effective per-wagon fee (amount / wagons) — one number for the customer. */ + perWagon: number; + /** Rate rows used; the first is recorded on the ledger row. */ + rates: Rate[]; +} + /** - * Partial wagon cancellation on a PAID booking, with a rebooking credit. + * Wagon cancellation on a PAID booking (partial or whole), with a rebooking + * credit. Cutting every wagon ends the source booking CANCELLED at T2; the + * credit then rebooks as a fresh booking under the same contract. * * Lifecycle (one ledger row per cycle, see BookingWagonCancellation): * T1 request — validate + price the fee, open the fee invoice. Nothing else @@ -91,6 +111,7 @@ export class BookingWagonCancellationService { private readonly repo: BookingWagonCancellationsRepository, private readonly bookingsRepository: BookingsRepository, private readonly billing: BillingService, + private readonly exchangeService: ExchangeService, @Inject(forwardRef(() => ContractBookingService)) private readonly contractBooking: ContractBookingService, @Inject(forwardRef(() => ClearanceMilestoneService)) @@ -120,14 +141,13 @@ export class BookingWagonCancellationService { }> { const booking = await this.loadCancellableBooking(bookingId); const cut = await this.resolveRequestedCut(booking, dto); - const rate = await this.feeRate(); - const feeAmount = round2(Number(rate.rateValue) * cut.wagons); + const fee = await this.priceFee(booking, cut); return { wagons: cut.wagons, weightTons: cut.weightTons, - feePerWagon: Number(rate.rateValue), - feeAmount, - feeCurrency: rate.currency, + feePerWagon: fee.perWagon, + feeAmount: fee.amount, + feeCurrency: fee.currency, creditAmount: this.creditFor(booking, cut.wagons), }; } @@ -146,8 +166,8 @@ export class BookingWagonCancellationService { } const cut = await this.resolveRequestedCut(booking, dto); - const rate = await this.feeRate(); - const feeAmount = round2(Number(rate.rateValue) * cut.wagons); + const fee = await this.priceFee(booking, cut); + const feeAmount = fee.amount; const creditAmount = this.creditFor(booking, cut.wagons); const row = await this.repo.create({ @@ -156,9 +176,11 @@ export class BookingWagonCancellationService { weightTons: cut.weightTons, cancelledQuantities: cut.quantities, creditAmount, - feeRateId: rate.id, + // ponytail: one FK for a mixed-size container cut records the first + // size's rate; the invoice line carries the effective per-wagon fee. + feeRateId: fee.rates[0].id, feeAmount, - feeCurrency: rate.currency, + feeCurrency: fee.currency, status: 'FEE_PENDING', reason: dto.reason ?? null, requestedByUserId: userId ?? null, @@ -173,15 +195,15 @@ export class BookingWagonCancellationService { type: WAGON_CANCEL_FEE_INVOICE_TYPE, companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: rate.currency, + currency: fee.currency, lines: [ { chargeType: 'CANCELLATION_FEE', description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`, quantity: cut.wagons, - unitRate: Number(rate.rateValue), + unitRate: fee.perWagon, amount: feeAmount, - currency: rate.currency, + currency: fee.currency, metadata: { wagonCancellationId: row.id }, }, ], @@ -343,12 +365,28 @@ export class BookingWagonCancellationService { const preSplitQuantities = booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight)); + // Whole-booking cut: nothing is left to ship, so the booking ends + // CANCELLED (frees the contract slot/cap for the rebook) and drops off its + // train. The credit row still points at it for T3. + const wagonsLeft = round2( + Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled), + ); + const isFull = wagonsLeft <= 0; await manager.getRepository(Booking).update(booking.id, { - wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)), - cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), - totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)), + wagonsRequired: Math.max(0, wagonsLeft), + cargoTotalWeightVgm: Math.max( + 0, + round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), + ), + totalAmount: Math.max( + 0, + round2(Number(booking.totalAmount) - Number(row.creditAmount)), + ), isSplit: true, preSplitQuantities, + ...(isFull + ? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null } + : {}), } as never); await manager.getRepository(BookingWagonCancellation).update(row.id, { @@ -360,11 +398,15 @@ export class BookingWagonCancellationService { }); const booking = await this.bookingsRepository.findById(row.bookingId); + if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking); if (booking) { + const whole = booking.status === 'CANCELLED'; this.notifyCustomer( booking, - 'Wagon cancellation confirmed', - `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, + whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed', + whole + ? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.` + : `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, ); } this.logger.log( @@ -372,6 +414,33 @@ export class BookingWagonCancellationService { ); } + /** + * Whole-booking cut: take the cancelled booking OFF its train entirely — + * schedule link, leftover wagon slots, window status — via the ops unassign + * path (no "removed from train" notice: the customer cancelled it). A stale + * link would keep showing the booking on the schedule AND poison every later + * auto wagon allocation on that train (the whole-train re-plan rejects a + * CANCELLED booking). Then re-run allocation so bookings held back by it + * (e.g. the rebooked credit) get their wagons. + */ + private async detachFromSchedule(booking: Booking): Promise { + const links = await this.dataSource + .getRepository(TrainScheduleBooking) + .find({ where: { bookingId: booking.id } }); + for (const link of links) { + try { + await this.trainScheduling.unassignBooking(link.trainScheduleId, booking.id, undefined, { + notifyCustomer: false, + }); + await this.trainScheduling.tryAutoWagonAllocation(link.trainScheduleId); + } catch (err) { + this.logger.error( + `Detach of cancelled booking ${booking.reference} from schedule ${link.trainScheduleId} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + // ── T3: rebook ────────────────────────────────────────────────────────────── async rebook( @@ -401,6 +470,8 @@ export class BookingWagonCancellationService { } const createDto = this.buildRebookDto(row, dto.scheduledDate); + // Same currency as the source booking — the credit is in it. + createDto.paymentCurrency = source.paymentCurrency ?? undefined; const created = await this.contractBooking.createUnderContract( source.contractId, createDto, @@ -413,9 +484,13 @@ export class BookingWagonCancellationService { // The freight is already paid (credit) — mark PAID and let the existing // paid-booking machinery place it. No invoice is generated for it. + // Its price IS the credit (already paid, in the source currency) — not a + // fresh live-rate quote; a later cut of the rebooked booking credits from it. await this.dataSource.getRepository(Booking).update(newBookingId, { paymentStatus: 'PAID', status: 'PAID', + totalAmount: Number(row.creditAmount), + paymentCurrency: source.paymentCurrency, }); await this.copyClearanceState(source, newBookingId); @@ -532,16 +607,16 @@ export class BookingWagonCancellationService { const live = liveBySize.get(cut.containerSize) ?? 0; if (cut.quantity > live) { throw new BadRequestException( - `Cannot cancel ${cut.quantity} × ${cut.containerSize}ft — the booking only has ${live}.`, + `Cannot cancel ${cut.quantity} × ${sizeFtOf(cut.containerSize)}ft — the booking only has ${live}.`, ); } bySize[cut.containerSize] = cut.quantity; - wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize)); + wagons += cut.quantity * wagonsPerUnitForSize(sizeFtOf(cut.containerSize)); } wagons = round2(wagons); - if (wagons >= totalWagons) { + if (wagons > totalWagons) { throw new BadRequestException( - 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + `Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`, ); } // Snapshot the LIFO-picked physical units up front (read-only — cargo is @@ -577,9 +652,11 @@ export class BookingWagonCancellationService { } } } - const weightShare = round3( - Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons), - ); + // Whole-booking cut takes the exact total, no ratio rounding. + const weightShare = + wagons >= totalWagons + ? round3(Number(booking.cargoTotalWeightVgm)) + : round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons)); return { wagons, weightTons: weightShare, @@ -594,17 +671,19 @@ export class BookingWagonCancellationService { if (!wagons || wagons <= 0) { throw new BadRequestException('Specify how many wagons to cancel.'); } - if (wagons >= totalWagons) { + if (wagons > totalWagons) { throw new BadRequestException( - 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + `Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`, ); } + // Whole-booking cut: all cargo, exactly. Otherwise proportional sizing. // ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item // rounding happens here too; switch to items_per_wagon_map sizing if bulk // PER_ITEM cancels ever need to be exact per item. - let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons); + const isFull = wagons >= totalWagons; + let tons = Number(booking.cargoTotalWeightVgm) * (isFull ? 1 : wagons / totalWagons); const isPerItem = booking.bulkTotalWeightTons != null; - tons = isPerItem ? Math.floor(tons) : round3(tons); + tons = isPerItem && !isFull ? Math.floor(tons) : round3(tons); if (tons <= 0) { throw new BadRequestException('The requested cut is too small to release cargo.'); } @@ -641,19 +720,23 @@ export class BookingWagonCancellationService { } const wagons = allocations.length; - if (wagons >= totalWagons) { + if (wagons > totalWagons) { throw new BadRequestException( - 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + `Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`, ); } + const isFull = wagons >= totalWagons; if (booking.freightType !== 'CONTAINER') { const allocated = allocations.reduce( (s, a) => s + Number(a.allocatedWeightTons || 0), 0, ); - const tons = - allocated > 0 + // Whole-booking cut takes the exact total; partial takes the wagons' + // allocated tonnage (ratio fallback when nothing is allocated yet). + const tons = isFull + ? round3(Number(booking.cargoTotalWeightVgm)) + : allocated > 0 ? round3(allocated) : round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons)); return { @@ -714,21 +797,83 @@ export class BookingWagonCancellationService { return round2(Number(booking.totalAmount) * (wagons / totalWagons)); } - private async feeRate(): Promise { - const rate = await this.dataSource.getRepository(Rate).findOne({ + /** + * Price the cut off the LIVE per-wagon cancellation rates for the booking's + * trade direction. Bulk bills the rate scoped to the booking's commodity × + * cancelled wagons; a container cut bills each size at its own container + * type's rate × the wagons that size occupies (two 20ft share one). A + * booking owned by a shipping line prices off that line's rates only — + * standard rates are never a fallback, matching booking pricing. + */ + private async priceFee(booking: Booking, cut: RequestedCut): Promise { + const raw = await this.priceFeeInRateCurrency(booking, cut); + // Bill in the booking's own currency (rates are configured in USD; ETB + // bookings pay ETB) — same USD→ETB conversion booking pricing applies. + const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; + const from = raw.currency === 'ETB' ? 'ETB' : 'USD'; + if (from === target) return raw; + const fx = await this.exchangeService.getRate(from, target); + return { + ...raw, + amount: round2(raw.amount * fx), + perWagon: round2(raw.perWagon * fx), + currency: target, + }; + } + + private async priceFeeInRateCurrency( + booking: Booking, + cut: RequestedCut, + ): Promise { + const rates = await this.dataSource.getRepository(Rate).find({ where: { rateType: WAGON_CANCELLATION_FEE_RATE_TYPE, rateUnit: 'PER_WAGON', status: 'LIVE', + tradeDirection: booking.tradeDirection, + shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(), }, order: { createdAt: 'DESC' }, }); - if (!rate) { - throw new BadRequestException( - 'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).', + const missing = (scope: string): BadRequestException => + new BadRequestException( + `No LIVE per-wagon cancellation fee is configured for ${scope} on ${booking.tradeDirection} — ask EDR to set it in the rate engine (surcharge: Cancellation).`, ); + + if (booking.freightType !== 'CONTAINER') { + const rate = rates.find( + (r) => !r.containerTypeId && !!r.cargoTypeId && r.cargoTypeId === booking.cargoTypeId, + ); + if (!rate) throw missing(`bulk cargo type ${booking.cargoType?.cargoTypeName ?? booking.cargoTypeId ?? '?'}`); + const amount = round2(Number(rate.rateValue) * cut.wagons); + return { amount, currency: rate.currency, perWagon: Number(rate.rateValue), rates: [rate] }; } - return rate; + + // Container: split the cancelled wagons across sizes in proportion to the + // wagon-space each size's units occupy, so the total always equals + // cut.wagons (whole wagons on an allocation cut, fractional on a quantity cut). + const bySize = Object.entries(cut.quantities.bySize ?? {}).filter(([, qty]) => qty > 0); + const spaceOf = ([size, qty]: [string, number]) => qty * wagonsPerUnitForSize(sizeFtOf(size)); + const totalSpace = bySize.reduce((s, e) => s + spaceOf(e), 0); + if (!bySize.length || totalSpace <= 0) throw missing('containers'); + const containerTypes = await this.dataSource.getRepository(ContainerType).find(); + const used: Rate[] = []; + let amount = 0; + let currency = ''; + for (const entry of bySize) { + const [size] = entry; + const sizeFt = sizeFtOf(size); + const typeIds = new Set( + containerTypes.filter((ct) => Number(ct.sizeFt) === sizeFt).map((ct) => ct.id), + ); + const rate = rates.find((r) => !!r.containerTypeId && typeIds.has(r.containerTypeId)); + if (!rate) throw missing(`${sizeFt || '?'}ft containers`); + currency = rate.currency; + used.push(rate); + amount += Number(rate.rateValue) * cut.wagons * (spaceOf(entry) / totalSpace); + } + amount = round2(amount); + return { amount, currency, perWagon: round2(amount / cut.wagons), rates: used }; } /** @@ -751,7 +896,7 @@ export class BookingWagonCancellationService { const live = lines.reduce((s, l) => s + Number(l.quantity ?? 0), 0); if (live < toDrop) { throw new BadRequestException( - `Booking changed since the request: only ${live} × ${size}ft left, cannot cancel ${toDrop}.`, + `Booking changed since the request: only ${live} × ${sizeFtOf(size)}ft left, cannot cancel ${toDrop}.`, ); } for (const line of lines) { @@ -795,7 +940,7 @@ export class BookingWagonCancellationService { }); await manager.getRepository(BookingContainer).update(line.id, { quantity: qty - drop, - wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))), + wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(sizeFtOf(size))), totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm), hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length, reeferQuantity: keptUnits.filter((u) => u.isReefer).length, @@ -883,7 +1028,7 @@ export class BookingWagonCancellationService { const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); await manager.getRepository(BookingContainer).update(line.id, { quantity: kept.length, - wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))), + wagonsRequired: round2(kept.length * wagonsPerUnitForSize(sizeFtOf(size))), totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm), hazardousQuantity: kept.filter((u) => u.isHazardous).length, reeferQuantity: kept.filter((u) => u.isReefer).length, @@ -902,9 +1047,9 @@ export class BookingWagonCancellationService { booking: Booking, tons: number, ): Promise { - if (tons >= Number(booking.cargoTotalWeightVgm)) { + if (tons > Number(booking.cargoTotalWeightVgm)) { throw new BadRequestException( - 'Booking changed since the request: the cut no longer leaves any cargo.', + 'Booking changed since the request: the cut exceeds the cargo left on the booking.', ); } if (booking.bulkTotalWeightTons != null) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 658629592..447292117 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -121,6 +121,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsService, BookingsRepository, BookingPricingService, + ContainerValidationService, BookingInvoiceService, BookingLifecycleNotifierService, BookingTransitionService, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index c029e36d4..310364927 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -16,6 +16,7 @@ import { computeFacets, FacetBucket } from '../../common/utils/facets.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; +import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; @@ -80,6 +81,8 @@ export interface BookingListFilterOptions { originYardId?: string; destinationYardId?: string; isGovernment?: 'true' | 'false'; + /** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */ + customerKind?: 'SHIPPING_LINE' | 'CUSTOMER'; consolidationPaired?: string; } @@ -781,16 +784,20 @@ export class BookingsRepository extends BaseRepository { // by TypeORM and crashes). .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') .addSelect('contract.reference', 'contract_reference') + // Shipping-line owner name for search only (no relation, see entity) — + // the list rows get `shippingLineCompany` hydrated by the service. + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = booking.shipping_line_company_id') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); - // Free-text search spans joined columns (company, contract) that only this - // list query joins — so it lives here, not in applyListFilters (shared - // with getListSummaryMetrics, whose query builder has no joins). + // Free-text search spans joined columns (company, shipping line, contract) + // that only this list query joins — so it lives here, not in + // applyListFilters (shared with getListSummaryMetrics, whose query builder + // has no joins). if (options.search) { qb.andWhere( - '(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)', + '(booking.reference ILIKE :search OR company.name ILIKE :search OR slc.name ILIKE :search OR contract.reference ILIKE :search)', { search: `%${options.search}%` }, ); } @@ -1043,6 +1050,11 @@ export class BookingsRepository extends BaseRepository { } else if (options.isGovernment === 'false') { qb.andWhere('booking.is_government = FALSE'); } + if (options.customerKind === 'SHIPPING_LINE') { + qb.andWhere('booking.shipping_line_company_id IS NOT NULL'); + } else if (options.customerKind === 'CUSTOMER') { + qb.andWhere('booking.shipping_line_company_id IS NULL'); + } if (omit !== 'tradeDirection' && options.tradeDirection) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index a9e9dfc34..0b5bf5a0a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1766,6 +1766,38 @@ export class BookingsService { pending.has(b.id); } this.attachPaymentDrainEnds(bookings); + await this.attachShippingLineCompanies(bookings); + } + + /** + * Batched name lookup for shipping-line-owned bookings (`companyId` null, + * `shippingLineCompanyId` set). No relation on the entity — the shipping-line + * module sits above bookings — so a raw query keyed off the loaded ids fills + * `shippingLineCompany` the way `company` is filled for customers. + */ + private async attachShippingLineCompanies(bookings: Booking[]): Promise { + const ids = [ + ...new Set( + bookings + .map((b) => b.shippingLineCompanyId) + .filter((id): id is string => id != null), + ), + ]; + if (!ids.length) return; + const rows: Array<{ id: string; name: string; email: string | null; phoneNumber: string | null }> = + await this.dataSource.query( + `SELECT id, name, email, phone_number AS "phoneNumber" + FROM freight.shipping_line_companies + WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, + [ids], + ); + const byId = new Map(rows.map((r) => [r.id, r])); + for (const b of bookings) { + const line = b.shippingLineCompanyId ? byId.get(b.shippingLineCompanyId) : undefined; + if (line) { + (b as Booking & { shippingLineCompany?: typeof line }).shippingLineCompany = line; + } + } } /** @@ -1822,6 +1854,7 @@ export class BookingsService { originYardId: filter.originYardId, destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, + customerKind: filter.customerKind, consolidationPaired: filter.consolidationPaired, // DTO carries 'true'/'false' strings (query params); the repo option is a // real boolean — convert, preserving "not filtered" when absent. @@ -2048,6 +2081,7 @@ export class BookingsService { originYardId: filter.originYardId, destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, + customerKind: filter.customerKind, consolidationPaired: filter.consolidationPaired, }; @@ -2134,6 +2168,8 @@ export class BookingsService { { path: "booking" }, ); + await this.attachShippingLineCompanies([booking]); + if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { diff --git a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts index de4dca661..158ac14de 100644 --- a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts @@ -67,9 +67,16 @@ export class ContainerValidationService { const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); if (!has20ft) return []; - const units = await this.load20ftUnits(booking); - if (units.length < 2) return []; + return this.validate20ftPairingUnits(await this.load20ftUnits(booking)); + } + /** + * Same rule over units that are not (yet) persisted — a completion payload + * being previewed or submitted. Shipping-line completion uses this: its + * cargo only hits the DB after the check passes. + */ + async validate20ftPairingUnits(units: Container20ftUnit[]): Promise { + if (units.length < 2) return []; const maxDiff = await this.maxPairDiffTons(); return validate20ftWeightPairing(units, maxDiff); } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 43d489bac..4ffde3d0d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -111,6 +111,14 @@ export class FilterBookingDto { @IsIn(['true', 'false']) isGovernment?: 'true' | 'false'; + @ApiPropertyOptional({ + enum: ['SHIPPING_LINE', 'CUSTOMER'], + description: 'Who booked: a shipping line (owned by shipping_line_company_id) or an ordinary customer company', + }) + @IsOptional() + @IsIn(['SHIPPING_LINE', 'CUSTOMER']) + customerKind?: 'SHIPPING_LINE' | 'CUSTOMER'; + @ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter customs vs self-clearance (non-customs) bookings', @@ -140,7 +148,7 @@ export class FilterBookingDto { @ApiPropertyOptional({ description: - 'Free-text search across booking reference, company name, and contract reference.', + 'Free-text search across booking reference, customer / shipping-line company name, and contract reference.', }) @IsOptional() @Transform(({ value }) => diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index b990aa8f0..666634cb8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // Consumed by NotificationInboxModule for portal recipient targeting. ExternalProfileRepository, CompanyProfileRepository, + // Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup + // already used for every customer company at onboarding, reused for EDR's own TIN. + ETradeService, ], }) export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 8c127e840..b29a4cfa5 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -110,6 +110,7 @@ function makeService(overrides?: { .fn() .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), } as never, // transit agents + { findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository ); return { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index e0d30a2be..43176da75 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { ContractDocPhase, isDeliveryOrderFileCode, @@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; +import { ContractsRepository } from './contracts.repository'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; @@ -155,6 +157,7 @@ export class BookingClearanceService { private readonly notifier: BookingLifecycleNotifierService, private readonly glExchangeService: GlExchangeService, private readonly transitAgentsService: TransitAgentsService, + private readonly contractsRepository: ContractsRepository, ) {} private async assertPhasedCustoms(booking: Booking): Promise { @@ -988,7 +991,39 @@ export class BookingClearanceService { const milestones = await this.workflowService.listMilestonesForBooking(b.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } - return filtered; + return this.attachContractSummary(filtered); + } + + /** + * Queue rows show the parent contract's reference and lane. Booking has no + * contract relation, and a bare initiated instance may not carry yards yet — + * so batch-load the contracts (with routes) and fill in what's missing: + * `contractReference` always, origin/destination yards only when the booking + * lacks them (its own route wins). + */ + private async attachContractSummary(bookings: Booking[]): Promise { + const ids = [...new Set(bookings.map((b) => b.contractId).filter(Boolean))] as string[]; + if (!ids.length) return bookings; + const contracts = await this.contractsRepository.findAll({ + where: { id: In(ids) }, + relations: { routes: { originYard: true, destinationYard: true } }, + }); + const byId = new Map(contracts.map((c) => [c.id, c])); + for (const b of bookings) { + const contract = b.contractId ? byId.get(b.contractId) : undefined; + if (!contract) continue; + const row = b as Booking & { contractReference?: string | null }; + row.contractReference = contract.reference ?? null; + if (b.originYard && b.destinationYard) continue; + const routes = contract.routes ?? []; + const route = + routes.find((r) => r.id === b.contractRouteId) ?? + (routes.length === 1 ? routes[0] : undefined); + if (!route) continue; + b.originYard = b.originYard ?? route.originYard; + b.destinationYard = b.destinationYard ?? route.destinationYard; + } + return bookings; } async djQueue(): Promise { @@ -1008,6 +1043,6 @@ export class BookingClearanceService { filtered.push(b); } } - return filtered; + return this.attachContractSummary(filtered); } } diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts index 6246d5a89..9b3bb5183 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DataSource } from "typeorm"; @@ -12,6 +13,9 @@ const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; /** * `query` is answered by shape: the first call is the system-state guard, the second is the * candidate lookup. Keeps the fake honest about the order the service actually asks in. + * + * `managerRow` backs `dataSource.manager.findOne`/`.update` — only exercised by the + * pre-reservation-rejection path (`failStalledCandidate`), so it defaults to the candidate itself. */ const build = ( opts: { @@ -19,6 +23,7 @@ const build = ( state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null }; candidate?: { id: string; invoiceNumber: string } | null; register?: jest.Mock; + managerRow?: { eimsStatus: EimsInvoiceStatus } | null; } = {}, ) => { const register = @@ -34,12 +39,17 @@ const build = ( return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []); }); + const managerUpdate = jest.fn().mockResolvedValue(undefined); + const managerFindOne = jest + .fn() + .mockResolvedValue(opts.managerRow === undefined ? { eimsStatus: EimsInvoiceStatus.NotSubmitted } : opts.managerRow); + const service = new EimsAutoSubmitService( - { query } as unknown as DataSource, + { query, manager: { findOne: managerFindOne, update: managerUpdate } } as unknown as DataSource, { get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService, { registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService, ); - return { service, register, query }; + return { service, register, query, managerUpdate, managerFindOne }; }; const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" }; @@ -121,6 +131,40 @@ describe("EimsAutoSubmitService.tick", () => { expect(register).toHaveBeenCalledTimes(1); }); + it("drains a pre-reservation rejection so the sweep advances, without touching the DB row's own reservation state", async () => { + const register = jest + .fn() + .mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" })); + const { service, managerFindOne, managerUpdate } = build({ candidate, register }); + + await expect(service.tick()).resolves.toBeUndefined(); + + expect(managerFindOne).toHaveBeenCalledTimes(1); + expect(managerUpdate).toHaveBeenCalledWith( + expect.anything(), + INVOICE_ID, + expect.objectContaining({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: expect.objectContaining({ message: "no related invoice" }), + }), + ); + }); + + it("leaves a row alone if it already moved past NOT_SUBMITTED by the time the rejection is handled", async () => { + const register = jest + .fn() + .mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" })); + const { service, managerUpdate } = build({ + candidate, + register, + managerRow: { eimsStatus: EimsInvoiceStatus.Submitting }, + }); + + await expect(service.tick()).resolves.toBeUndefined(); + + expect(managerUpdate).not.toHaveBeenCalled(); + }); + it("does not start a second tick while one is still filing", async () => { let release: () => void = () => {}; const register = jest.fn().mockImplementation( diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts index fb5a0d1f6..bad283817 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts @@ -1,12 +1,14 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Cron } from "@nestjs/schedule"; 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 { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; -import { EimsInvoiceStatus } from "./eims-registration.types"; +import { EimsInvoiceError, EimsInvoiceStatus } from "./eims-registration.types"; /** * Files issued invoices with MoR EIMS on a timer. @@ -67,21 +69,62 @@ export class EimsAutoSubmitService { const candidate = await this.nextCandidate(); if (!candidate) return; - const view = await this.registration.registerInvoiceWithEims(candidate.id); - this.logger.log( - `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + - (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), - ); + try { + const view = await this.registration.registerInvoiceWithEims(candidate.id); + this.logger.log( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + + (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), + ); + } catch (err) { + // Every other failure path inside registerInvoiceWithEims persists FAILED/UNKNOWN itself + // (settleFailure) before throwing. A BadRequestException is the one exception: it is only + // ever thrown *before* a reservation is taken (config assertion, DEB/CRE validation), so + // nothing is persisted — left alone, this candidate is picked again next tick forever, a + // permanent head-of-line block on every invoice behind it. Drain it instead. + if (err instanceof BadRequestException) { + await this.failStalledCandidate(candidate, err); + } else { + throw err; + } + } } catch (err) { // Never let a filing failure kill the job. The outcome is already persisted on the invoice - // (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the - // next tick at the guard above. + // (FAILED or UNKNOWN with the gateway's own message, or drained by failStalledCandidate + // above), and a blocked system number stops the next tick at the guard above. this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`); } finally { this.running = false; } } + /** + * Mark a pre-reservation rejection as FAILED so the sweep advances past it — but only if the + * invoice is still exactly where this tick left it. A reservation's own transactions + * (SUBMITTING/UNKNOWN, or a system-wide block) are authoritative; this must never clobber them, + * so the status is re-read fresh rather than trusted from the stale `candidate` row. + */ + private async failStalledCandidate( + candidate: { id: string; invoiceNumber: string }, + err: BadRequestException, + ): Promise { + const current = await this.dataSource.manager.findOne(Invoice, { where: { id: candidate.id } }); + if (current?.eimsStatus !== EimsInvoiceStatus.NotSubmitted) { + this.logger.warn( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation, but is ` + + `no longer NOT_SUBMITTED (${current?.eimsStatus ?? "not found"}) — leaving state untouched.`, + ); + return; + } + const lastError: EimsInvoiceError = { kind: "VALIDATION", message: err.message, at: new Date().toISOString() }; + await this.dataSource.manager.update(Invoice, candidate.id, { + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: lastError, + } as QueryDeepPartialEntity); + this.logger.error( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation: ${err.message}`, + ); + } + /** Why filing is currently impossible for this system number, or null when it is free. */ private async systemBlockReason(): Promise { const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] = diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts index b68a4a32f..a5f837c1e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config"; import { EimsConfig } from "../../config/eims.config"; import { EimsConfigException } from "./eims.errors"; +const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/; + +/** + * A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the + * first line. Never the actual key material — PEM headers aren't secret, the base64 body is. + */ +const describeBytes = (bytes: Buffer): string => { + const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?"); + return `${bytes.length} bytes, starts with "${preview}"`; +}; + /** * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. * @@ -24,25 +35,61 @@ export class EimsCredentialsProvider { return this.config.get("eims")!; } - /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ + /** + * RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM + * text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a + * literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if + * none is usable. + */ getPrivateKey(): KeyObject { if (this.privateKey) return this.privateKey; - const path = this.cfg.privateKeyPath; - if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg; + const source = privateKeyPem + ? "EIMS_PRIVATE_KEY" + : privateKeyBase64 + ? "EIMS_PRIVATE_KEY_BASE64" + : `EIMS_PRIVATE_KEY_PATH (${path})`; + if (!privateKeyPem && !privateKeyBase64 && !path) { + throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + } + + let bytes: Buffer; + try { + bytes = privateKeyPem + ? Buffer.from(privateKeyPem, "utf8") + : privateKeyBase64 + ? Buffer.from(privateKeyBase64, "base64") + : readFileSync(path); + } catch (err) { + throw new EimsConfigException( + `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`, + ); + } + + // Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own + // error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a + // genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes. + if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) { + throw new EimsConfigException( + `EIMS private key from ${source} does not look like a PEM key after decoding ` + + `(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` + + `or truncation, and not base64 applied twice.`, + ); + } let key: KeyObject; try { - key = createPrivateKey(readFileSync(path)); + key = createPrivateKey(bytes); } catch (err) { - // The path is operational information, not a secret; the key material never appears. + // The source is operational information, not a secret; the key material never appears. throw new EimsConfigException( - `EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`, + `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`, ); } if (key.asymmetricKeyType !== "rsa") { throw new EimsConfigException( - `EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, + `EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, ); } @@ -51,11 +98,26 @@ export class EimsCredentialsProvider { return key; } - /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ + /** + * Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued. + * `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the + * pasted text respectively); otherwise read from `certificatePath`. + */ getCertificateBase64(): string { if (this.certificateBase64) return this.certificateBase64; - const path = this.cfg.certificatePath; + const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg; + if (pem) { + this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64"); + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`); + return this.certificateBase64; + } + if (inline) { + this.certificateBase64 = inline; + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`); + return this.certificateBase64; + } + if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); let bytes: Buffer; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 8e7be0197..4c1c82c10 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -157,6 +157,12 @@ export interface EimsContextInput { session: EimsSessionContext; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; + /** `DocumentDetails.Type` — defaults to "INV" in the mapper when omitted. */ + documentType?: EimsMapperContext["documentType"]; + /** Required (by the mapper) when documentType is DEB/CRE. */ + reason?: string | null; + /** `ReferenceDetails.RelatedDocument` — the original invoice's IRN, required for DEB/CRE. */ + relatedDocument?: string | null; } export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext { @@ -200,11 +206,16 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, + buyerCountryCodes: invoice.buyerCountryCodes, buyerRegionCodes: invoice.buyerRegionCodes, buyerWeredaCodes: invoice.buyerWeredaCodes, + buyerCityCodes: invoice.buyerCityCodes, // TEMPORARY — see EimsInvoiceConfig.buyerIdType. buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, exchangeRate: input.exchangeRate ?? null, + documentType: input.documentType, + reason: input.reason ?? null, + relatedDocument: input.relatedDocument ?? null, }; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 8c2a33cfc..f560ff917 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -10,8 +10,10 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; +import { buildEimsSeller } from "./eims-invoice-context"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsInvoiceStatus } from "./eims-registration.types"; @@ -172,6 +174,9 @@ const build = ( } as unknown as EimsAuthService, { notify } as unknown as NotificationInboxService, { directSend } as unknown as NotificationsService, + // Same static-config seller the real EimsSellerCacheService falls back to when it has never + // successfully fetched e-Trade — matches prior behavior for every test in this file. + { getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService, ); /** @@ -295,6 +300,58 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); + it("files a credit note with Type/Reason/RelatedDocument from the invoice row", async () => { + const original = invoiceRow({ + id: "original-invoice", + invoiceNumber: "INV-20260807-00001", + eimsIrn: IRN, + }); + const db = new FakeDb([ + invoiceRow({ + eimsDocumentType: "CRE", + eimsReason: "Overbilled freight charge", + relatedInvoice: original, + } as Partial), + ]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + + await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" }); + expect(request.ReferenceDetails.RelatedDocument).toBe(IRN); + }); + + it("refuses a credit/debit note whose related invoice was never registered, before touching a counter", async () => { + const original = invoiceRow({ id: "original-invoice", eimsIrn: null }); + const db = new FakeDb([ + invoiceRow({ + eimsDocumentType: "DEB", + eimsReason: "Additional handling", + relatedInvoice: original, + } as Partial), + ]); + const postSigned = jest.fn(); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7 }); // unchanged — never reserved + }); + + it("refuses a credit/debit note with no related invoice set at all", async () => { + const db = new FakeDb([ + invoiceRow({ eimsDocumentType: "CRE", eimsReason: "x", relatedInvoice: null } as Partial), + ]); + const postSigned = jest.fn(); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(postSigned).not.toHaveBeenCalled(); + }); + it("takes SourceSystem from the token session, not from configuration", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue(okResponse()); @@ -422,6 +479,59 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); + it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest + .fn() + .mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed")); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsConfigException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsIrn: null, + eimsLastError: expect.objectContaining({ kind: "CONFIG" }), + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 7, + }); + }); + + it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => { + // Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls + // settleFailure — a throw here left the reservation permanently orphaned (a real live incident: + // 500 on register, then every subsequent attempt 409'd "already in flight" until manually + // resolved). This never reaches postSigned at all — the mapper throws before submit() is called. + const db = new FakeDb([ + invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }), + ]); + const postSigned = jest.fn(); + + // The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the + // point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own + // known exception types. + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( + /no MoR country code mapping/, + ); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: expect.objectContaining({ kind: "LOCAL" }), + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 7, + }); + }); + it("treats a success response with no IRN as a failed registration", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 317f4ec65..a823e5ddf 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -13,6 +13,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { + EimsDocumentType, EimsInvoiceRequest, EimsMapperLine, toEimsInvoice, @@ -25,13 +26,10 @@ 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"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; -import { - assertEimsInvoiceConfig, - buildEimsContext, - buildEimsSeller, -} from "./eims-invoice-context"; +import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; import { EimsInvoiceError, EimsInvoiceStatus, @@ -82,6 +80,7 @@ export class EimsInvoiceRegistrationService { private readonly auth: EimsAuthService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly sellerCache: EimsSellerCacheService, ) {} private get cfg(): EimsConfig { @@ -96,6 +95,27 @@ export class EimsInvoiceRegistrationService { const invoice = await this.loadInvoiceForMapping(invoiceId); if (invoice.eimsIrn) return this.toView(invoice); + // Debit/credit notes (confirmed by MoR support: same endpoint, Type DEB/CRE + Reason, + // ReferenceDetails.RelatedDocument = the original's IRN) must fail here — before a counter is + // touched — if the original was never actually registered. + const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV"; + let relatedDocument: string | null = null; + if (documentType !== "INV") { + if (!invoice.relatedInvoice) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_REQUIRED", + message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`, + }); + } + if (!invoice.relatedInvoice.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_NOT_REGISTERED", + message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`, + }); + } + relatedDocument = invoice.relatedInvoice.eimsIrn; + } + // Authenticate before reserving: the source system comes from the token, and the state row is // keyed by it. A login failure here costs nothing — no counter has been consumed yet. const session = await this.auth.getSessionContext(); @@ -103,24 +123,29 @@ export class EimsInvoiceRegistrationService { const reservation = await this.reserve(invoiceId, session.systemNumber); if (!reservation) return this.getEimsStatus(invoiceId); - // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. - const request = toEimsInvoice( - invoice, - buildEimsSeller(cfg), - buildEimsContext(cfg, { - // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber - // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. - documentNumber: reservation.documentNumber, - invoiceCounter: reservation.invoiceCounter, - previousIrn: reservation.previousIrn, - session, - }), - ); - let irn: string; let ackDate: string | undefined; let signedQR: string | undefined; try { + // The request can only be built now: InvoiceCounter and PreviousIrn come from the + // reservation. Building it — and everything after — stays inside this try: a reservation is + // held from here on, and *any* failure past this point, mapper or wire, must release it + // through settleFailure rather than leave it orphaned as a permanent system-wide block. + const request = toEimsInvoice( + invoice, + this.sellerCache.getSellerDetails(cfg), + buildEimsContext(cfg, { + // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber + // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. + documentNumber: reservation.documentNumber, + invoiceCounter: reservation.invoiceCounter, + previousIrn: reservation.previousIrn, + session, + documentType, + reason: invoice.eimsReason, + relatedDocument, + }), + ); // Deliberately outside every transaction — no DB lock is held across the wire. const result = await this.submit(request); irn = result.irn; @@ -446,6 +471,15 @@ export class EimsInvoiceRegistrationService { * 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. + * + * Any error that is *not* an `EimsApiException` is also deterministic, on a different basis: + * every error that actually touches the wire is normalized to `EimsApiException` before it gets + * here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call + * threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` — + * pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by + * touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer + * country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before + * any HTTP call went out, and releasing the reservation is always safe, never a guess. */ private async settleFailure( invoiceId: string, @@ -453,10 +487,12 @@ export class EimsInvoiceRegistrationService { err: unknown, ): Promise { const api = err instanceof EimsApiException ? err : null; - const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; + // Never touched the wire (see the doc comment above) — always safe to release, whatever it is. + const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true; const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; + const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL"; const lastError: EimsInvoiceError = { - kind: api?.kind ?? "UNKNOWN", + kind: api?.kind ?? localKind, message: (err as Error)?.message ?? "unknown error", httpStatus: api?.httpStatus, details: api?.details, @@ -563,10 +599,14 @@ export class EimsInvoiceRegistrationService { type: NotificationType.GENERIC, priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH, title: deterministic - ? "EIMS rejected an invoice" + ? error.kind === "CONFIG" || error.kind === "LOCAL" + ? "EIMS filing failed before reaching MoR" + : "EIMS rejected an invoice" : "EIMS filing unresolved — all further filing is blocked", body: deterministic - ? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` + ? error.kind === "CONFIG" || error.kind === "LOCAL" + ? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it and file again.` + : `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` : `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`, link: `/dashboard/invoices/${invoiceId}`, data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" }, @@ -639,7 +679,7 @@ export class EimsInvoiceRegistrationService { ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { id: invoiceId }, - relations: { company: true, companyProfile: true }, + relations: { company: true, companyProfile: true, relatedInvoice: true }, }); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index c02c7870e..7d417551f 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -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 type { Response } from "express"; import { BookingStaff } from "../../common/booking-guards"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { sendPdf } from "../billing/billing.controller"; import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; @@ -110,4 +112,16 @@ export class EimsInvoiceController { listReceipts(@Param("id", ParseUUIDPipe) id: string) { 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); + } } diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts new file mode 100644 index 000000000..841bfcc54 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts @@ -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, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts index b9b59cc27..93f50e827 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts @@ -4,6 +4,7 @@ import { DataSource } from "typeorm"; import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; +import { InvoiceDocumentService } from "../billing/documents/invoice-document.service"; import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; @@ -40,10 +41,16 @@ class FakeDb { } 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), + findOne: async ( + entity: unknown, + options: { where: { id?: string; invoiceId?: string } }, + ) => { + 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 } }) => [...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId), save: async (_entity: unknown, data: Record) => { @@ -71,6 +78,7 @@ const build = ( db: FakeDb, postBearer: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined), + documents: { render: jest.Mock } = { render: jest.fn() }, ) => new EimsReceiptService( db.asDataSource(), @@ -78,6 +86,7 @@ const build = ( { postBearer } as unknown as EimsClientService, { getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService, { directSend } as unknown as NotificationsService, + documents as unknown as InvoiceDocumentService, ); const okResponse = (over: Record = {}) => ({ @@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => { 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/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts index dfbbd688b..2bdb6cbe2 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts @@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; +import { InvoiceDocumentService } from "../billing/documents/invoice-document.service"; 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 { toReceiptDocumentModel } from "./eims-receipt-document.mapper"; 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"; @@ -52,6 +54,7 @@ export class EimsReceiptService { private readonly client: EimsClientService, private readonly auth: EimsAuthService, private readonly notifications: NotificationsService, + private readonly documents: InvoiceDocumentService, ) {} 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; + 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 ──────────────────────────────────────────────────────────────────────────────── private async submit( diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts new file mode 100644 index 000000000..7b4ff1b3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts @@ -0,0 +1,155 @@ +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; + +const registrationData = (over: Record = {}) => ({ + companyName: "Ethio-Djibouti Railway PLC (eTrade)", + region: "Addis Ababa", + zone: "Bole", + woreda: "Yeka", + mobilePhone: "0911000000", + regularPhone: "", + ...over, +}); + +const build = (cfg: EimsConfig = eimsConfig()) => { + const resolveCompanyData = jest.fn(); + const extractRegistrationData = jest.fn().mockReturnValue(registrationData()); + const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService; + const config = { get: () => cfg } as unknown as ConfigService; + const service = new EimsSellerCacheService(etrade, config); + return { service, resolveCompanyData, extractRegistrationData, cfg }; +}; + +const CODES = { + buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: { Yeka: "99" }, + buyerCityCodes: { Bole: "101" }, +}; + +describe("EimsSellerCacheService.getSellerDetails", () => { + it("static config wins over a conflicting e-Trade value", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ + companyInfo: {}, + businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked + }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + // The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's + // differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)". + expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C."); + }); + + it("e-Trade fills a field only when the static value is blank", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ + sellerLegalName: "", + sellerRegion: "", + sellerWereda: "", + sellerCity: null, + ...CODES, + }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + expect(seller.Region).toBe("13"); + expect(seller.Wereda).toBe("99"); + expect(seller.City).toBe("101"); + }); + + it("VatNumber and Email are always the static value, never touched by e-Trade", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.VatNumber).toBe("0000000000"); + expect(seller.Email).toBe("finance@example.et"); + }); + + it("falls back to the static config entirely when e-Trade has never been reachable", () => { + const cfg = eimsConfig(); + const { service } = build(cfg); + + // No refresh() ever called/succeeded — cached stays null. + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName); + expect(seller.Region).toBe(cfg.invoice.sellerRegion); + }); + + it("does no I/O at all — filing never triggers an e-Trade request", () => { + const { service, resolveCompanyData, cfg } = build(); + + service.getSellerDetails(cfg); + service.getSellerDetails(cfg); + + expect(resolveCompanyData).not.toHaveBeenCalled(); + }); +}); + +describe("EimsSellerCacheService.refresh", () => { + it("keeps the previous snapshot when a refresh fails", async () => { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down")); + await service.refresh(); + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + }); + + it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => { + jest.useFakeTimers(); + try { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves + const refreshing = service.refresh(); + await jest.advanceTimersByTimeAsync(10_000); + await refreshing; + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + } finally { + jest.useRealTimers(); + } + }); + + it("does not start a second e-Trade request while one is already in flight", async () => { + const { service, resolveCompanyData } = build(); + let resolveCall: (value: unknown) => void = () => {}; + resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve))); + + const first = service.refresh(); + const second = service.refresh(); + resolveCall({ companyInfo: {}, businessInfo: {} }); + await Promise.all([first, second]); + + expect(resolveCompanyData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts new file mode 100644 index 000000000..a8d242929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper"; +import { buildEimsSeller } from "./eims-invoice-context"; + +const has = (value: string | null | undefined): value is string => Boolean(value && value.trim()); + +/** + * EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same + * e-Trade business-registry lookup already used for every customer company at onboarding — instead + * of the whole thing being hand-maintained `EIMS_SELLER_*` config. + * + * **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates + * `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested + * with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a + * value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the + * e-Trade-derived value when the static one is blank; a static value, once set, is never replaced. + * This also means the durable fallback is the static config, not this cache — the in-memory + * snapshot disappearing on a process restart is harmless, not a reliability gap: every field it + * could supply already has a working static value today, so filing is unaffected either way. + * + * `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response + * shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field + * exists anywhere in what e-Trade returns. They stay on static config permanently, same as + * `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch. + * + * Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/ + * position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain + * field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a + * refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate + * deviations from that precedent, both because `ETradeService` has no request timeout configured + * at all (confirmed by reading it) and is a third-party dependency, unlike the DB: + * - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot; + * - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight + * returns the same in-flight promise instead of starting a duplicate request. + * + * `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration + * never depends on e-Trade being reachable at that moment, whether or not it ever has been. + */ +@Injectable() +export class EimsSellerCacheService implements OnModuleInit { + private readonly logger = new Logger(EimsSellerCacheService.name); + + /** Only the e-Trade-derived fields, used solely to fill a blank static value. */ + private cached: Partial | null = null; + /** Concurrency guard — a second `refresh()` call while one is running joins it. */ + private refreshing: Promise | null = null; + + // ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a + // filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever + // matters; EDR's own business registration changes rarely enough that this is a generous + // ceiling, not a real one. + private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; + /** Bounded locally since `ETradeService` itself sets none — see the class comment. */ + private static readonly REFRESH_TIMEOUT_MS = 10_000; + + constructor( + private readonly etrade: ETradeService, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + void this.refresh(); + const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS); + timer.unref?.(); + } + + /** + * Static config wins whenever it's non-blank — that's the value already confirmed against MoR. + * e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on + * every registration. + */ + getSellerDetails(cfg: EimsConfig): EimsSellerDetails { + const fallback = buildEimsSeller(cfg); + const e = this.cached; + return { + ...fallback, + LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName), + Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone), + Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region), + Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda), + City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City), + }; + } + + /** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */ + async refresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = this.doRefresh().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + + private async doRefresh(): Promise { + try { + const cfg = this.config.get("eims")!; + const { companyInfo, businessInfo } = await this.withTimeout( + this.etrade.resolveCompanyData(cfg.tin), + EimsSellerCacheService.REFRESH_TIMEOUT_MS, + ); + if (!businessInfo) return; // no licence on file yet — keep the previous snapshot + const data = this.etrade.extractRegistrationData(businessInfo, companyInfo); + const codes = cfg.invoice; + this.cached = { + LegalName: data.companyName || undefined, + Phone: data.mobilePhone || data.regularPhone || undefined, + // e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the + // same buyer code maps, since the geography is objective, not buyer-specific, despite the + // env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to + // getSellerDetails' static-config fallback. + Region: resolveOptionalCode(data.region, codes.buyerRegionCodes), + Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes), + City: resolveOptionalCode(data.zone, codes.buyerCityCodes), + }; + } catch (err) { + this.logger.warn( + `EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`, + ); + } + } + + /** + * `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only + * stops *waiting* on the request — nothing cancels the underlying HTTP call (no + * `AbortController` wired into `ETradeService`), so a timed-out request may still complete in + * the background; its result is simply never read. + */ + private withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts index 5e408ddf7..f8210cab6 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts @@ -97,8 +97,14 @@ describe("EimsSignerService", () => { }); describe("EimsCredentialsProvider", () => { - const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => - new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); + const providerFor = (cfg: { + privateKeyPath?: string; + certificatePath?: string; + privateKeyBase64?: string; + certificateBase64?: string; + privateKeyPem?: string; + certificatePem?: string; + }) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService); it("fails clearly when the key path is unset", () => { expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); @@ -115,4 +121,52 @@ describe("EimsCredentialsProvider", () => { writeFileSync(emptyPath, ""); expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); }); + + it("loads the key from inline base64, no file involved", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers inline base64 over the path when both are set", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + // A path that would fail if it were ever actually read. + const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from inline base64 as-is, no re-encoding", () => { + const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"); + expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64); + }); + + it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => { + // Simulates the real failure this guards against: a truncated/mangled env var still decodes + // as *some* bytes, but not a key — OpenSSL's own error here gives no hint why. + const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64"); + expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow( + /does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s, + ); + }); + + it("loads the key from the raw PEM env var directly, no encoding step", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ privateKeyPem: pem }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers the raw PEM var over base64 and path when all three are set", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ + privateKeyPem: pem, + privateKeyBase64: Buffer.from("garbage").toString("base64"), + privateKeyPath: join(dir, "nope.key"), + }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from the raw PEM env var, re-encoded to base64", () => { + const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64(); + expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64")); + }); }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index 93df29da2..a55951db4 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, + buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code + buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code taxCodeByChargeType: {}, taxRateByChargeType: {}, exciseByChargeType: {}, @@ -57,6 +59,10 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ systemType: EIMS_SYSTEM_TYPE, privateKeyPath: "/dev/null", certificatePath: "/dev/null", + privateKeyBase64: "", + certificateBase64: "", + privateKeyPem: "", + certificatePem: "", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, autoSubmit: false, diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts index 3be21fdd3..853b32c29 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.errors.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -10,7 +10,9 @@ export type EimsFailureKind = | "FORBIDDEN" | "RULE_VALIDATION" | "SERVER" - | "UNKNOWN"; + | "UNKNOWN" + | "CONFIG" + | "LOCAL"; /** Raised when EIMS is disabled or its credential files are unusable. */ export class EimsConfigException extends ServiceUnavailableException { diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 7aa59b3ef..5d55a7ee8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -3,6 +3,8 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; +import { DocumentsModule } from "../billing/documents/documents.module"; +import { CompaniesModule } from "../companies/companies.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { EimsAuthService } from "./eims-auth.service"; @@ -13,6 +15,7 @@ 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 { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSignerService } from "./eims-signer.service"; import { EimsReceipt } from "./entities/eims-receipt.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -29,6 +32,13 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]), NotificationInboxModule, 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, + // For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with + // ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule, + // so this stays a plain one-directional import, not a new cycle. + CompaniesModule, ], controllers: [EimsInvoiceController], providers: [ @@ -40,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAutoSubmitService, EimsCancellationService, EimsReceiptService, + EimsSellerCacheService, ], exports: [ EimsAuthService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 73ae42a0d..bca8ab278 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -53,7 +53,7 @@ export class CreateRateDto { @ApiPropertyOptional({ enum: CARGO_KINDS, description: - 'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.', + 'Whether a customs clearance / cancellation rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or CANCELLATION. Not stored — container fees carry a containerTypeId, bulk fees a cargoTypeId.', }) @IsOptional() @IsIn([...CARGO_KINDS]) diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts index e44dcdb5f..39d6174de 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts @@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => { ).toEqual(["PER_TON"]); }); + it("bills the wagon cancellation fee per wagon only, whatever the cargo kind", () => { + for (const cargoKind of ["CONTAINER", "BULK"] as const) { + expect( + allowedRateUnits({ appliesTo: "OTHER", trigger: "CANCELLATION", cargoKind }), + ).toEqual(["PER_WAGON"]); + } + expect( + allowedRateUnits({ + appliesTo: "OTHER", + trigger: "CANCELLATION", + cargoKind: "BULK", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).toEqual(["PER_WAGON"]); + }); + it("treats per-ton and per-item as the same booking quantity", () => { expect(isBulkQuantityUnit("PER_TON")).toBe(true); expect(isBulkQuantityUnit("PER_ITEM")).toBe(true); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 3d5b1401e..5adf225ca 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -16,8 +16,7 @@ export const isBulkQuantityUnit = (unit: string): boolean => * Which rate units make sense for a given rate shape. The weighting basis is * driven by the *type* of thing being billed — a container leg bills per * container, bulk freight per ton, an intercity move can be per-km, a - * cancellation is a flat/per-invoice fee, and overweight is always per excess - * ton. This keeps the rate table dynamic yet non-conflicting: the admin can + * cancellation is a per-wagon fee, and overweight is always per excess ton. This keeps the rate table dynamic yet non-conflicting: the admin can * only pick a unit the pricing engine knows how to apply. * * A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers @@ -29,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean => export function allowedRateUnits(input: { appliesTo: RateAppliesTo; trigger: RateTrigger; - /** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */ + /** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */ cargoKind?: 'CONTAINER' | 'BULK' | null; /** Unit of measure of the bulk commodity the rate is scoped to, when any. */ cargoUnitOfMeasure?: CargoUom; @@ -64,7 +63,9 @@ function unitsForShape(input: { // wagon the empties ride back on, or a flat fee. return ['PER_CONTAINER', 'PER_WAGON', 'FLAT']; case 'CANCELLATION': - return ['FLAT', 'PER_INVOICE']; + // Wagon cancellation fee — scales with the cancelled wagon count, so + // per wagon is the only unit the wagon-cancel flow can apply. + return ['PER_WAGON']; case 'CUSTOMS_CLEARANCE': // Sold per cargo kind: container fees bill per box or per wagon, bulk // fees per ton or per wagon. Billed on the booking invoice. diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 1902a757a..977a31718 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -25,6 +25,19 @@ import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.reposito /** Categories priced per rail leg — they carry an origin → destination yard pair. */ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY']; +/** + * Surcharges sold per cargo kind: the admin says container or bulk, a + * container fee then names its container type and a bulk fee its commodity. + */ +const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION']; +/** Surcharges that keep a trade direction (everything else is direction-agnostic). */ +const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [ + 'CUSTOMS_CLEARANCE', + 'CANCELLATION', + 'WITH_RETURN', + 'LASHING', + 'FUEL', +]; /** The yard pair a rate scopes to, already validated against its direction. */ interface YardScope { @@ -152,7 +165,11 @@ export class RatesService { appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], ): boolean { - return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING'; + return ( + this.isRouteScoped(appliesTo, trigger) || + trigger === 'LASHING' || + trigger === 'CANCELLATION' + ); } /** @@ -244,10 +261,13 @@ export class RatesService { }): void { const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input; const { containerTypeId, cargoTypeId } = input; - if (trigger === 'CUSTOMS_CLEARANCE') { + if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') { + // Both fees are sold per direction + cargo kind + type: customs clearance + // per lane, the wagon cancellation fee per direction only. + const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance'; if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { throw new BadRequestException( - 'A customs clearance rate must say whether it covers IMPORT or EXPORT.', + `A ${fee} rate must say whether it covers IMPORT or EXPORT.`, ); } // Sold per cargo kind: a container fee names the container type it covers @@ -255,29 +275,29 @@ export class RatesService { // that absence is what marks it as the bulk fee. if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') { throw new BadRequestException( - 'A customs clearance rate must say whether it covers containers or bulk.', + `A ${fee} rate must say whether it covers containers or bulk.`, ); } if (cargoKind === 'CONTAINER' && !containerTypeId) { throw new BadRequestException( - 'A container customs clearance rate must name the container type it covers.', + `A container ${fee} rate must name the container type it covers.`, ); } if (cargoKind === 'BULK' && containerTypeId) { throw new BadRequestException( - 'A bulk customs clearance rate cannot be scoped to a container type.', + `A bulk ${fee} rate cannot be scoped to a container type.`, ); } - // The bulk customs fee names the commodity it covers (sugar and - // fertilizer clear differently). + // The bulk fee names the commodity it covers (sugar and fertilizer + // clear — and cancel — differently). if (cargoKind === 'BULK' && !cargoTypeId) { throw new BadRequestException( - 'A bulk customs clearance rate must name the bulk cargo type it covers.', + `A bulk ${fee} rate must name the bulk cargo type it covers.`, ); } if (cargoKind === 'CONTAINER' && cargoTypeId) { throw new BadRequestException( - 'A container customs clearance rate cannot be scoped to a bulk cargo type.', + `A container ${fee} rate cannot be scoped to a bulk cargo type.`, ); } return; @@ -547,22 +567,21 @@ export class RatesService { const trigger = dto.trigger as Rate['trigger']; // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // the engine never accidentally narrows a surcharge by container/direction. - // Exceptions: customs clearance and empty-container return keep direction + - // container type — both are sold per lane (and per container type). + // Exceptions: the directed surcharges (customs clearance, cancellation, + // empty-container return, lashing, fuel) keep direction + cargo scope. const isSurcharge = trigger !== 'ALWAYS'; - const cargoKind = - trigger === 'CUSTOMS_CLEARANCE' - ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) - : null; + const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger) + ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) + : null; const containerTypeId = trigger === 'WITH_RETURN' || - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER') + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER') ? (dto.containerTypeId ?? null) : isSurcharge ? null : (dto.containerTypeId ?? null); const cargoTypeId = - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || trigger === 'LASHING' || trigger === 'FUEL' ? (dto.cargoTypeId ?? null) @@ -574,10 +593,7 @@ export class RatesService { // intercity lane is stored as DOMESTIC, since appliesTo = OTHER says // nothing about the direction.) const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || - trigger === 'WITH_RETURN' || - trigger === 'LASHING' || - trigger === 'FUEL' + DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) ? (dto.tradeDirection ?? null) : isSurcharge || appliesTo === 'INTERCITY' ? null @@ -758,16 +774,15 @@ export class RatesService { // A patch that leaves the cargo kind unsaid keeps the one the rate already // has — read back off its container scope (container fees carry the type). - const cargoKind = - trigger !== 'CUSTOMS_CLEARANCE' - ? null - : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? - (existing.containerTypeId ? 'CONTAINER' : 'BULK')); + const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger) + ? null + : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? + (existing.containerTypeId ? 'CONTAINER' : 'BULK')); const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN' || - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER'); + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER'); const containerTypeId = !keepsContainerType ? null : dto.containerTypeId !== undefined @@ -775,7 +790,7 @@ export class RatesService { : existing.containerTypeId; const keepsCargoType = !isSurcharge || - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || trigger === 'LASHING' || trigger === 'FUEL'; const cargoTypeId = !keepsCargoType @@ -784,10 +799,7 @@ export class RatesService { ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || - trigger === 'WITH_RETURN' || - trigger === 'LASHING' || - trigger === 'FUEL' + DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) ? dto.tradeDirection !== undefined ? dto.tradeDirection : existing.tradeDirection diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts index 4a5fc86bb..2d1bbf6f4 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => { previewTrainSchedule: jest.fn(), unassignBooking: jest.fn(), assignBookingsToSchedule: jest.fn(), + windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}), + emitWindowState: jest.fn().mockResolvedValue(undefined), }; schedulingRescheduleRepository = { createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }), @@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => { }); trainSchedulesRepository.updateStatus.mockResolvedValue(undefined); trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' }); + // An OPEN window's close must follow the new departure (this is the + // portal's "closes in" countdown) — the derived fields ride along with the + // date write. + const newCloses = new Date('2099-06-22T08:00:00.000Z'); + trainSchedulingService.windowFieldsForNewDeparture.mockResolvedValue({ + windowClosesAt: newCloses, + }); const result = await service.maintenanceReschedule( 'sched-1', @@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => { expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith( 'sched-1', 'DRAFT', - { scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') }, + { + scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'), + windowClosesAt: newCloses, + }, txManager, ); + expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith( + schedule, + new Date('2099-06-22T10:00:00.000Z'), + ); expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith( expect.objectContaining({ trigger: 'TRAIN_MAINTENANCE', diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index 7283c874a..7c41ee43a 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -227,17 +227,25 @@ export class SchedulingRescheduleService { // through this manager without editing TrainSchedulingService. A failure // between those steps and this block can still leave partial state; a human // must finish the full cross-service transaction threading. + // The booking window must follow the new departure (an OPEN window's + // "closes in" countdown is capped at departure − close offset; PRE_WINDOW / + // DONE re-derive their open/close). Same math as maintenanceReschedule. + const windowFields = newDeparture + ? await this.trainSchedulingService.windowFieldsForNewDeparture( + schedule, + newDeparture, + ) + : {}; + await this.dataSource.transaction(async (manager) => { if (newDeparture) { - // M7: raw write of scheduledDepartureDate. We deliberately do NOT - // delegate to TrainSchedulingService.updateScheduleDate, which only - // permits a date change while windowPhase === 'PRE_WINDOW' and would - // reject reschedules of already-open (SCHEDULED) trains. Consequence: - // the booking-window fields are NOT re-derived for the new date here. + // Raw write of scheduledDepartureDate: updateScheduleDate only permits a + // date change while windowPhase === 'PRE_WINDOW' and would reject + // reschedules of already-open (SCHEDULED) trains. await this.trainSchedulesRepository.updateStatus( scheduleId, schedule.status as TrainScheduleStatus, - { scheduledDepartureDate: newDeparture }, + { scheduledDepartureDate: newDeparture, ...windowFields }, manager, ); } @@ -263,6 +271,7 @@ export class SchedulingRescheduleService { // `newDeparture` is null when the date was unchanged, so retained customers // are not falsely told the train was rescheduled. await this.notifyRescheduleOutcome(dto, newDeparture); + if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId); return { plan, schedule: assignResult }; } diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts index 0f7be3f18..ce5a7fad8 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts @@ -10,6 +10,8 @@ import { In, Repository } from "typeorm"; import { BookingPricingService } from "../bookings/booking-pricing.service"; import { BookingTransitionService } from "../bookings/booking-transition.service"; import { BookingsService } from "../bookings/bookings.service"; +import type { Container20ftUnit } from "../bookings/container-pairing.util"; +import { ContainerValidationService } from "../bookings/container-validation.service"; import { BookingContainer } from "../bookings/entities/booking-container.entity"; import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity"; import { Booking } from "../bookings/entities/booking.entity"; @@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService { private readonly trainSchedulingService: TrainSchedulingService, private readonly bookingBatchService: BookingBatchService, private readonly creditsService: ShippingLineCreditsService, + private readonly containerValidationService: ContainerValidationService, ) {} + /** + * 20ft weight-pairing check over the completion payload — the same rule the + * customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t): + * two 20ft sharing a wagon must be within the cap. Preview surfaces the + * messages; completion hard-blocks on them. Runs off the DTO so nothing is + * persisted before the check passes. + */ + private async pairingViolationMessages( + dto: CompleteShippingLineBookingDto, + ): Promise { + const units: Container20ftUnit[] = []; + for (const line of dto.containers ?? []) { + const containerType = await this.resolveContainerType(line); + if (containerType.sizeFt !== 20) continue; + (line.units ?? []).forEach((u, idx) => + units.push({ + label: u.containerNumber || `20ft-${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + }), + ); + } + const violations = + await this.containerValidationService.validate20ftPairingUnits(units); + return violations.map((v) => v.message); + } + /** Same session→owner resolution every shipping-line entry point uses. */ private async requireShippingLine(userId: string) { const shippingLine = @@ -193,6 +222,17 @@ export class ShippingLineBookingCompletionService { ); } + // Unbalanced 20ft pairs can never be planned onto wagons — refuse before + // any cargo/credit write below. Same block the contract path applies. + if (booking.freightType === "CONTAINER") { + const pairing = await this.pairingViolationMessages(dto); + if (pairing.length) { + throw new BadRequestException( + `Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`, + ); + } + } + // Completion is booking time. A lane with trains DEDICATED to this line // has no window concept at all: the line books whenever it wants until the // train's close offset. Only a lane with no dedicated train falls back to @@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService { computed.appliedModifiers, ); + // Pairing is reported, not thrown: the confirm modal shows it next to the + // price (as the customer form does) and disables confirm; /complete + // hard-blocks the same payload. + const pairingErrors = + booking.freightType === "CONTAINER" + ? await this.pairingViolationMessages(dto) + : []; + return { totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, warnings: computed.warnings, + overweightLines: computed.overweightLines, + pairingErrors, }; } diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts index 16b2836e1..ad290def8 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts @@ -132,37 +132,33 @@ export class ShippingLineCompaniesService { * Email always goes out — it is required at registration and is the only * channel guaranteed to reach a foreign-registered line. SMS is sent in * addition when the number is domestic, since the gateway silently drops - * anything else (see `CustomerResetService`). Two links are two independent - * single-use tickets; whichever the line opens first works. + * anything else (see `CustomerResetService`). Both carry the SAME single-use + * ticket: minting retires earlier tickets, so two mints would kill the email + * link the moment the SMS went out. * * Reports the email send, as that is the one that is always attempted. */ async sendActivationLink(shippingLine: ShippingLineCompany) { const scope = `shipping line ${shippingLine.id}`; + const channels = [ResetChannel.Email]; + if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) { + channels.push(ResetChannel.Phone); + } - const emailed = await this.customerResetService.sendResetLinkToUser( + const sent = await this.customerResetService.sendResetLinkToUserOnChannels( shippingLine.userId, - ResetChannel.Email, + channels, { scope, allowWithoutCredential: true }, ); + const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null; if (!emailed) { this.logger.error( `Activation email not sent for shipping line ${shippingLine.id} — no reachable address`, ); } - - if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) { - const texted = await this.customerResetService.sendResetLinkToUser( - shippingLine.userId, - ResetChannel.Phone, - { scope, allowWithoutCredential: true }, - ); - if (!texted) { - this.logger.warn( - `Activation SMS not sent for shipping line ${shippingLine.id}`, - ); - } + if (channels.includes(ResetChannel.Phone) && !sent.some((s) => s.channel === ResetChannel.Phone)) { + this.logger.warn(`Activation SMS not sent for shipping line ${shippingLine.id}`); } return emailed; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 93a187a95..54056404a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces import { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; import { - Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res, + Body, Controller, Delete, Get, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, Res, } from "@nestjs/common"; import { CurrentUser } from "@edr/api-common"; import { @@ -37,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto"; -import { RecordCheckpointDto } from "../dto/record-checkpoint.dto"; +import { + DispatchScheduleDto, + RecordCheckpointDto, + UpdateCheckpointDto, +} from "../dto/record-checkpoint.dto"; import { ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, @@ -516,9 +520,14 @@ export class TrainSchedulingController { @Post("schedules/:id/dispatch") @BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch) - @ApiOperation({ summary: "Dispatch a scheduled train" }) - dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.dispatchSchedule(id); + @ApiOperation({ + summary: "Dispatch a scheduled train (optional actual departure time, past allowed)", + }) + dispatchSchedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: DispatchScheduleDto, + ) { + return this.trainSchedulingService.dispatchSchedule(id, dto); } @Get("intercity/bookings") @@ -956,6 +965,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.recordCheckpoint(id, dto); } + @Patch("schedules/:id/checkpoints/:sequenceNo") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Edit a logged leg's time/note (no side effects; allowed while dispatched or after arrival)", + }) + updateCheckpoint( + @Param("id", ParseUUIDPipe) id: string, + @Param("sequenceNo", ParseIntPipe) sequenceNo: number, + @Body() dto: UpdateCheckpointDto, + ) { + return this.trainSchedulingService.updateCheckpoint(id, sequenceNo, dto); + } + @Post("schedules/:id/arrive") @TrainSchedulingUpdate() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index da7ebc0e7..1495185f0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -10,8 +10,6 @@ import { Min, } from 'class-validator'; -import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator'; - export class RecordCheckpointDto { @ApiProperty({ description: 'Station position along the route (0 = origin).' }) @IsInt() @@ -24,17 +22,17 @@ export class RecordCheckpointDto { kind?: TrainCheckpointKind; /** - * A checkpoint records where the train is as staff observe it, and the final - * one arrives the schedule — so a backdated value rewrites the journey after - * the fact. Only "now" is accepted; omit the field and the service stamps it. + * When the train was actually at the station — staff often log after the + * fact, so a past value is allowed. The service rejects the future and any + * value out of order with the neighbouring legs. */ @ApiProperty({ required: false, - description: 'ISO timestamp; defaults to now. Cannot be earlier than now.', + description: + 'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.', }) @IsOptional() @IsISO8601() - @IsNotBackdated() occurredAt?: string; @ApiProperty({ required: false }) @@ -43,3 +41,30 @@ export class RecordCheckpointDto { @MaxLength(500) note?: string; } + +/** Edit an already-logged leg's time/note — no side effects (no unload, no arrival). */ +export class UpdateCheckpointDto { + @ApiProperty({ + required: false, + description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.', + }) + @IsOptional() + @IsISO8601() + occurredAt?: string; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string | null; +} + +export class DispatchScheduleDto { + @ApiProperty({ + required: false, + description: 'Actual departure time; defaults to now. Past allowed, future rejected.', + }) + @IsOptional() + @IsISO8601() + actualDepartureAt?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 43a169058..15cf4f592 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => { let wagonBookingAllocationsRepository: Record; let wagonAllocationContainerItemsRepository: Record; let wagonAllocationBulkLoadsRepository: Record; + let trainCheckpointEventsRepository: Record; beforeEach(() => { // findGroupSiblings runs a query builder off dataSource.manager; default it @@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => { findByIdWithFullGraph: jest.fn(), findAll: jest.fn(), updateStatus: jest.fn(), + update: jest.fn(), maxReferenceSequence: jest.fn().mockResolvedValue(0), }; trainScheduleBookingsRepository = { @@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => { findAll: jest.fn().mockResolvedValue([]), }; - const trainCheckpointEventsRepository = { + trainCheckpointEventsRepository = { findBySchedule: jest.fn().mockResolvedValue([]), findAll: jest.fn().mockResolvedValue([]), create: jest.fn(), @@ -1638,6 +1640,61 @@ describe('TrainSchedulingService', () => { }); }); + describe('updateCheckpoint — leg time correction', () => { + const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h)); + const schedule = { + id: 'sch-track', + status: 'ARRIVED', + routeId: null, + originStationId: 'y0', + destinationStationId: 'y1', + actualDepartureAt: t(8), + }; + const events = () => [ + { id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) }, + { id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) }, + ]; + + beforeEach(() => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule); + trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events()); + }); + + it('rejects a leg time earlier than the previous leg', async () => { + await expect( + service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }), + ).rejects.toThrow(/cannot be earlier than/); + expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled(); + }); + + it('rejects a leg time later than the next leg', async () => { + await expect( + service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }), + ).rejects.toThrow(/cannot be later than/); + }); + + it('rejects a future time', async () => { + const future = new Date(Date.now() + 3_600_000).toISOString(); + await expect( + service.updateCheckpoint('sch-track', 1, { occurredAt: future }), + ).rejects.toThrow(/future/); + }); + + it('accepts an in-order past time and re-stamps arrival for the final leg', async () => { + await service.updateCheckpoint('sch-track', 1, { + occurredAt: t(11).toISOString(), + note: 'late log', + }); + expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', { + occurredAt: t(11), + note: 'late log', + }); + expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', { + actualArrivalAt: t(11), + }); + }); + }); + describe('effectiveWagonsRequired', () => { const effective = (booking: unknown): number => (service as never as { effectiveWagonsRequired(b: unknown): number }) @@ -1764,5 +1821,31 @@ describe('TrainSchedulingService', () => { expect(written.windowPhase).toBeUndefined(); expect(written.windowOpensAt).toBeUndefined(); }); + + it('moves an OPEN export close to the new departure but keeps the open', async () => { + const opensAt = new Date('2027-06-19T03:00:00.000Z'); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue( + doneExportSchedule({ + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + windowOpensAt: opensAt, + windowClosesAt: new Date('2027-06-20T03:00:00.000Z'), + }), + ); + + // Departure pushed 3 days later → close = new departure − 120min; the + // open customers already booked against stays untouched. + await service.maintenanceReschedule('sch-done', { + newDepartureDate: '2027-06-23T05:00:00.000Z', + } as never); + + const written = scheduleUpdate.mock.calls[0][1]; + expect(written.scheduledDepartureDate).toEqual( + new Date('2027-06-23T05:00:00.000Z'), + ); + expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z')); + expect(written.windowOpensAt).toBeUndefined(); + expect(written.windowPhase).toBeUndefined(); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 780d65305..03535135d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -164,6 +164,8 @@ import { } from '../booking-batch.constants'; import { orderConsistWagons } from '../consist-order.util'; import { + bookingCloseCutoff, + clampCloseToOfficeHours, computeExportWindowTimes, computeImportWindowTimes, earliestSchedulableDeparture, @@ -174,7 +176,11 @@ import { import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; import { BookingJourneyService } from '../booking-journey.service'; import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository'; -import { RecordCheckpointDto } from '../dto/record-checkpoint.dto'; +import { + DispatchScheduleDto, + RecordCheckpointDto, + UpdateCheckpointDto, +} from '../dto/record-checkpoint.dto'; import { RouteMilestone } from '../../routes/entities/route-milestone.entity'; import { deriveTradeDirection } from '../../../common/derive-trade-direction.util'; import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service'; @@ -469,7 +475,7 @@ export class TrainSchedulingService { * used for lifecycle changes outside the window tick (create, cancel, * finalize, restamp). A push failure must never break the mutation. */ - private async emitWindowState(scheduleId: string): Promise { + async emitWindowState(scheduleId: string): Promise { try { const fresh = await this.trainSchedulesRepository.findById(scheduleId); // Dedicated shipping-line departures are never announced to the portal — @@ -1155,66 +1161,73 @@ export class TrainSchedulingService { } /** - * Maintenance reschedule: the admin moves a train (with everything aboard) to - * a new departure. Unlike {@link updateScheduleDate} this runs at ANY window - * phase and inside the booking lead window — a maintenance move is an - * operational fact, not a planning choice. What moves and what stays: + * Booking-window fields that must follow a train's departure moving to + * `departure` (any window phase). Shared by every reschedule path so the + * "closes in" countdown always tracks the real departure. * - * - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every - * aboard/targeted booking's scheduledDate (the day-pool queries key on it, - * so a booking left on the old day would fall out of its own train's pool). - * - STAYS: train set, wagon assignments, schedule↔booking links, route, - * maxWagons, and the window RULE snapshot. Stamped window times are only - * re-derived for PRE_WINDOW schedules (their window hasn't run yet); a - * schedule mid- or post-window keeps its timeline untouched. + * PRE_WINDOW: the stamped open/close were derived from the old departure + * and the window hasn't opened yet, so re-derive them from the schedule's + * own rule snapshot against the new date (joining the target day's route + * group timeline when one exists, exactly like updateScheduleDate). * - * Customers of every moved booking are notified (maintenanceMoved). + * DONE: the window already finished (e.g. the close offset hit and then the + * train was moved to a later departure). The window must follow the new + * departure, so it REOPENS: re-derive open/close the same way, reset the + * phase to PRE_WINDOW and clamp a past open into the present so the tick + * opens it immediately. A FULL train stays closed — there is nothing left + * to sell — and so does one whose re-derived window would already be over. + * + * OPEN: customers are already booking against the open they were shown, so + * the open stays put — but the close was capped at the OLD departure's + * cutoff, so it must follow the new one (import: open + duration under + * office hours, capped at the cutoff; export: the cutoff itself). Moving + * the train later extends the "closes in" countdown, moving it earlier + * shortens it (a close now in the past is picked up by the next tick). + * + * DOC_REVIEW/PAYMENT keep their running timeline. */ - async maintenanceReschedule( - id: string, - dto: MaintenanceRescheduleDto, - ): Promise { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); - if (!schedule) { - throw new NotFoundException(`Train schedule ${id} not found`); - } - if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { - throw new BadRequestException( - `Cannot reschedule a ${schedule.status.toLowerCase()} train`, - ); - } - - const departure = new Date(dto.newDepartureDate); - if (Number.isNaN(departure.getTime())) { - throw new BadRequestException('Invalid departure date.'); - } - if (departure.getTime() <= Date.now()) { - throw new BadRequestException('New departure must be in the future.'); - } - - const deltaMs = - departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime(); - const scheduledArrivalDate = schedule.scheduledArrivalDate - ? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs) - : undefined; - - // PRE_WINDOW: the stamped open/close were derived from the old departure - // and the window hasn't opened yet, so re-derive them from the schedule's - // own rule snapshot against the new date (joining the target day's route - // group timeline when one exists, exactly like updateScheduleDate). - // - // DONE: the window already finished (e.g. the close offset hit and then the - // train was moved to a later departure). The window must follow the new - // departure, so it REOPENS: re-derive open/close the same way, reset the - // phase to PRE_WINDOW and clamp a past open into the present so the tick - // opens it immediately. A FULL train stays closed — there is nothing left - // to sell — and so does one whose re-derived window would already be over. - // - // Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline. + async windowFieldsForNewDeparture( + schedule: TrainSchedule, + departure: Date, + ): Promise< + Partial< + Pick< + TrainSchedule, + | 'windowOpensAt' + | 'windowClosesAt' + | 'windowPhase' + | 'bookingWindowStatus' + | 'docReviewCompletedAt' + | 'docReviewEndsAt' + | 'paymentPhaseEndsAt' + > + > + > { const reopenFromDone = schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL'; + const shiftOpenClose = + schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null; const windowFields = - schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone + shiftOpenClose + ? await (async () => { + const merged = effectiveWindowConfig( + schedule, + await this.getWindowConfig(), + ); + const opensAt = schedule.windowOpensAt!; + const cutoff = bookingCloseCutoff(departure, schedule.direction, merged); + let closesAt = cutoff; + if (schedule.direction !== 'EXPORT') { + closesAt = clampCloseToOfficeHours( + opensAt, + new Date(opensAt.getTime() + merged.windowDurationHours * 3_600_000), + merged, + ); + if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff; + } + return { windowClosesAt: closesAt }; + })() + : schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone ? await (async () => { const merged = effectiveWindowConfig( schedule, @@ -1262,6 +1275,55 @@ export class TrainSchedulingService { }; })() : {}; + return windowFields; + } + + /** + * Maintenance reschedule: the admin moves a train (with everything aboard) to + * a new departure. Unlike {@link updateScheduleDate} this runs at ANY window + * phase and inside the booking lead window — a maintenance move is an + * operational fact, not a planning choice. What moves and what stays: + * + * - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every + * aboard/targeted booking's scheduledDate (the day-pool queries key on it, + * so a booking left on the old day would fall out of its own train's pool). + * - STAYS: train set, wagon assignments, schedule↔booking links, route, + * maxWagons, and the window RULE snapshot. Stamped window times are + * re-derived for PRE_WINDOW schedules (their window hasn't run yet); an + * OPEN schedule keeps its open but its close follows the new departure; + * DOC_REVIEW/PAYMENT keep their timeline untouched. + * + * Customers of every moved booking are notified (maintenanceMoved). + */ + async maintenanceReschedule( + id: string, + dto: MaintenanceRescheduleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot reschedule a ${schedule.status.toLowerCase()} train`, + ); + } + + const departure = new Date(dto.newDepartureDate); + if (Number.isNaN(departure.getTime())) { + throw new BadRequestException('Invalid departure date.'); + } + if (departure.getTime() <= Date.now()) { + throw new BadRequestException('New departure must be in the future.'); + } + + const deltaMs = + departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime(); + const scheduledArrivalDate = schedule.scheduledArrivalDate + ? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs) + : undefined; + + const windowFields = await this.windowFieldsForNewDeparture(schedule, departure); await this.dataSource.getRepository(TrainSchedule).update(id, { scheduledDepartureDate: departure, @@ -1306,7 +1368,7 @@ export class TrainSchedulingService { * in the future) using the CURRENT global-rules config. Schedules already OPEN or * past their window are left untouched — customers may have booked against the * times they were shown, so those stay frozen. Returns the count re-stamped. - */ + */ async restampPendingWindows(): Promise { const cfg = await this.getWindowConfig(); const now = new Date(); @@ -2160,7 +2222,15 @@ export class TrainSchedulingService { return { ...detail, warnings, deferredBookings }; } - async unassignBooking(scheduleId: string, bookingId: string, userId?: string) { + async unassignBooking( + scheduleId: string, + bookingId: string, + userId?: string, + opts: { + /** false = system detach (e.g. booking cancelled) — no "removed from train, rebook" notice. */ + notifyCustomer?: boolean; + } = {}, + ) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -2287,7 +2357,9 @@ export class TrainSchedulingService { const removedBooking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId }, relations: { company: true } }); - if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking); + if (removedBooking && opts.notifyCustomer !== false) { + this.bookingNotifier.removedFromTrain(removedBooking); + } this.logger.log( `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, ); @@ -2635,7 +2707,7 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } - async dispatchSchedule(scheduleId: string) { + async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -2643,6 +2715,9 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } + // Staff may record the departure after the fact — past is fine, future is not. + const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date(); + this.assertNotFuture(now, 'Departure time'); await this.assertImportDjiboutiMayDepart(schedule); // Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon) // never blocks departure — the dispatch confirm dialog warns and staff decide. @@ -2667,7 +2742,6 @@ export class TrainSchedulingService { } } - const now = new Date(); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); if (setLocomotiveIds.length) { @@ -4150,6 +4224,7 @@ export class TrainSchedulingService { ? TrainCheckpointKind.Arrived : TrainCheckpointKind.Passed); const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); + await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt); // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. const [existing] = await this.trainCheckpointEventsRepository.findAll({ @@ -4173,8 +4248,14 @@ export class TrainSchedulingService { }); } + // The origin DEPARTED checkpoint IS the departure — keep the schedule's + // headline timestamp on the same clock the operator just entered. + if (dto.sequenceNo === 0) { + await this.trainSchedulesRepository.update(scheduleId, { actualDepartureAt: occurredAt }); + } + if (dto.sequenceNo === finalSeq) { - await this.arriveSchedule(scheduleId); + await this.arriveSchedule(scheduleId, occurredAt); } else { // Mid-corridor auto-unload: bookings destined for this yard alight the // moment the train is recorded here — the yard operator no longer has to @@ -4210,11 +4291,133 @@ export class TrainSchedulingService { return this.getScheduleCheckpoints(scheduleId); } + /** + * Correct an already-logged leg's time/note. Pure edit: no auto-unload, no + * position fix, no arrival — those already happened when the leg was logged. + * Allowed on DISPATCHED and ARRIVED trains (a journey is corrected after the + * fact as often as during it). The origin/final legs also re-stamp the + * schedule's departure/arrival so the headline figures follow the edit. + */ + async updateCheckpoint(scheduleId: string, sequenceNo: number, dto: UpdateCheckpointDto) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if ( + schedule.status !== TrainScheduleStatusEnum.Dispatched && + schedule.status !== TrainScheduleStatusEnum.Arrived + ) { + throw new BadRequestException('Only DISPATCHED or ARRIVED trains have checkpoints to edit'); + } + const stations = await this.buildScheduleStations(schedule); + const station = stations.find((s) => s.sequenceNo === sequenceNo); + if (!station) { + throw new BadRequestException(`Station ${sequenceNo} is not on this route`); + } + // Match by yard, like getScheduleCheckpoints — legacy rows may carry an + // older station numbering. + const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); + const existing = + events.find((e) => e.yardId === station.yardId) ?? + events.find((e) => e.sequenceNo === sequenceNo); + if (!existing) { + throw new BadRequestException(`Station ${station.label} has not been logged yet`); + } + + const patch: Partial = {}; + if (dto.occurredAt) { + const occurredAt = new Date(dto.occurredAt); + await this.assertCheckpointTime(schedule, stations, sequenceNo, occurredAt, existing.id); + patch.occurredAt = occurredAt; + } + if (dto.note !== undefined) patch.note = dto.note; + if (Object.keys(patch).length) { + await this.trainCheckpointEventsRepository.update(existing.id, patch); + } + + if (patch.occurredAt) { + const finalSeq = stations[stations.length - 1].sequenceNo; + if (sequenceNo === 0) { + await this.trainSchedulesRepository.update(scheduleId, { + actualDepartureAt: patch.occurredAt, + }); + } else if (sequenceNo === finalSeq && schedule.status === TrainScheduleStatusEnum.Arrived) { + await this.trainSchedulesRepository.update(scheduleId, { + actualArrivalAt: patch.occurredAt, + }); + } + } + + return this.getScheduleCheckpoints(scheduleId); + } + + private assertNotFuture(at: Date, what: string) { + if (Number.isNaN(at.getTime())) { + throw new BadRequestException(`${what} is not a valid date`); + } + // Small skew allowance so an honest "now" from a client clock passes. + if (at.getTime() > Date.now() + 60_000) { + throw new BadRequestException(`${what} cannot be in the future`); + } + } + + /** + * A leg's time must not be in the future and must sit in corridor order: + * no earlier than every logged leg before it (and the dispatch time, for + * legs after the origin), no later than every logged leg after it. + * `ignoreEventId` excludes the row being edited from its own bounds. + */ + private async assertCheckpointTime( + schedule: TrainSchedule, + stations: { sequenceNo: number; yardId: string; label: string }[], + sequenceNo: number, + occurredAt: Date, + ignoreEventId?: string, + ) { + this.assertNotFuture(occurredAt, 'Checkpoint time'); + + const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo])); + const labelBySeq = new Map(stations.map((s) => [s.sequenceNo, s.label])); + const events = (await this.trainCheckpointEventsRepository.findBySchedule(schedule.id)).filter( + (e) => e.id !== ignoreEventId, + ); + const seqOf = (e: TrainCheckpointEvent) => seqByYard.get(e.yardId) ?? e.sequenceNo; + const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; + + let floor: { at: Date; label: string } | null = null; + let ceil: { at: Date; label: string } | null = null; + for (const e of events) { + const s = seqOf(e); + if (s < sequenceNo && (!floor || e.occurredAt > floor.at)) { + floor = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` }; + } + if (s > sequenceNo && (!ceil || e.occurredAt < ceil.at)) { + ceil = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` }; + } + } + // The origin leg rewrites the departure itself; every later leg must + // follow it. + if (sequenceNo > 0 && schedule.actualDepartureAt && (!floor || schedule.actualDepartureAt > floor.at)) { + floor = { at: schedule.actualDepartureAt, label: 'departure' }; + } + + if (floor && occurredAt < floor.at) { + throw new BadRequestException( + `Checkpoint time cannot be earlier than ${floor.label} (${fmt(floor.at)})`, + ); + } + if (ceil && occurredAt > ceil.at) { + throw new BadRequestException( + `Checkpoint time cannot be later than ${ceil.label} (${fmt(ceil.at)})`, + ); + } + } + /** * Mark a dispatched train arrived: close out the schedule, move the locomotive * and wagons to the destination yard, and free the assets for re-use. */ - async arriveSchedule(scheduleId: string) { + async arriveSchedule(scheduleId: string, arrivedAt?: Date) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -4223,7 +4426,9 @@ export class TrainSchedulingService { throw new BadRequestException('Only DISPATCHED trains can arrive'); } - const now = new Date(); + // The arrival clock: the operator's entered time when arriving via the final + // checkpoint (already order/future-checked there), else now. + const now = arrivedAt ?? new Date(); await this.dataSource.transaction(async (manager) => { await this.trainSchedulesRepository.updateStatus( @@ -4409,6 +4614,7 @@ export class TrainSchedulingService { originStation: true, destinationStation: true, scheduleBookings: { booking: true }, + shippingLineCompany: true, }, order: { [sortBy]: sortOrder } as never, skip, @@ -6107,6 +6313,10 @@ export class TrainSchedulingService { origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + // Dedicated shipping-line departure (hidden from customers) — the list + // highlights these rows so staff can tell them apart at a glance. + shippingLineCompanyId: schedule.shippingLineCompanyId ?? null, + shippingLineCompanyName: schedule.shippingLineCompany?.name ?? null, // Built train (Train Builder) behind this departure, when scheduled by train. train: schedule.trainSet?.train ? { @@ -8997,40 +9207,101 @@ export class TrainSchedulingService { } const allocRepo = this.dataSource.getRepository(WagonBookingAllocation); + // Cargo type → allowed wagon types rides along: for bulk, the commodity's + // own wagon-type list (the planner's rule) decides, not only the wagon + // type's generic supportedLoadTypes. const loadAllocations = (trainSetWagonId: string) => - allocRepo.find({ where: { trainSetWagonId } }); + allocRepo.find({ + where: { trainSetWagonId }, + relations: { booking: { cargoType: { wagonTypes: true } } }, + }); const sourceAllocs = await loadAllocations(source.id); if (!sourceAllocs.length) { throw new BadRequestException('Source wagon has no load to move'); } - // Target: a slot of this train set, or an empty consist-only wagon of the - // built train (physical wagon with no slot row yet). + // Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing — + // Gelan→Adama and Adama→Doraleh loads ride the same wagon in two slots), so + // "the slot on that wagon" only means the one whose leg overlaps the moving + // load's leg. Null board/alight = the schedule's own endpoints. + const stops = await this.stopYardsForSchedule(schedule); + const spanOf = (slot: { + boardYardId?: string | null; + alightYardId?: string | null; + }): [number, number] => { + const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; + const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : stops.length - 1; + return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to]; + }; + const overlaps = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1]; + const sourceSpan = spanOf(source); + + // Target: a slot of this train set, or a physical wagon of this train — + // coupled-but-empty consist wagon (built train), or a wagon already pinned + // by another slot of this set (then: the overlapping-leg slot, or a fresh + // slot for a free leg). const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null; - const wagonForTarget = slotById - ? null - : schedule.trainSet?.trainId - ? await this.dataSource.getRepository(Wagon).findOne({ - where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId }, - relations: { wagonType: true }, - }) - : null; + let wagonForTarget: Wagon | null = null; + if (!slotById) { + const wagon = await this.dataSource.getRepository(Wagon).findOne({ + where: { id: dto.targetWagonId }, + relations: { wagonType: true }, + }); + const onThisTrain = + !!wagon && + ((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) || + slots.some((w) => w.physicalWagonId === wagon.id)); + wagonForTarget = onThisTrain ? wagon : null; + } if (!slotById && !wagonForTarget) { throw new NotFoundException('Target wagon is not part of this schedule'); } - // A physical wagon holds at most one slot. When the caller addressed the - // wagon directly but a slot is already pinned to it, move into that slot - // rather than minting a second one on the same wagon. const targetSlot = slotById ?? (wagonForTarget - ? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null) + ? (slots.find( + (w) => + w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan), + ) ?? null) : null); const consistWagon = targetSlot ? null : wagonForTarget; + + // Leg clash guard: after the move, no two slots on one physical wagon may + // ride the same edge. Source load → target wagon; on a swap, target load → + // source wagon. + const targetPhysicalId = targetSlot?.physicalWagonId ?? consistWagon?.id ?? null; + const clashOn = ( + physicalWagonId: string | null, + excludeSlotId: string | null, + span: [number, number], + ) => + !!physicalWagonId && + slots.some( + (w) => + w.physicalWagonId === physicalWagonId && + w.id !== excludeSlotId && + w.id !== source.id && + (w.allocations?.length ?? 0) > 0 && + overlaps(spanOf(w), span), + ); + if (clashOn(targetPhysicalId, targetSlot?.id ?? null, sourceSpan)) { + throw new BadRequestException( + 'That wagon already carries another load on the same leg — pick a wagon free on that leg.', + ); + } const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; if (targetSlot && targetSlot.id === source.id) { return this.getTrainScheduleById(scheduleId); } + if ( + targetSlot && + targetAllocs.length && + clashOn(source.physicalWagonId ?? null, source.id, spanOf(targetSlot)) + ) { + throw new BadRequestException( + 'Swap refused: the source wagon already carries another load on the incoming load’s leg.', + ); + } const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), @@ -9043,10 +9314,25 @@ export class TrainSchedulingService { slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`; const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) => slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon'); + // Bulk is allowed on a wagon type when every bulk load's cargo type lists + // it (cargo-type ↔ wagon-type config, same rule the wagon planner uses). + const bulkCargoAllows = (allocs: WagonBookingAllocation[], wagonTypeId?: string) => { + const bulk = allocs.filter((a) => (a.loadType ?? 'CONTAINER').toUpperCase() === 'BULK'); + return ( + !!wagonTypeId && + bulk.length > 0 && + bulk.every((a) => + (a.booking?.cargoType?.wagonTypes ?? []).some((wt) => wt.id === wagonTypeId), + ) + ); + }; const checkReceives = ( allocs: WagonBookingAllocation[], label: string, - wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined, + wagonType: + | { id?: string; code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } + | null + | undefined, capacityTons: number, ) => { const incoming = loadTypesOf(allocs); @@ -9057,6 +9343,7 @@ export class TrainSchedulingService { const ok = supported.includes(loadType) || (loadType === 'CONTAINER' && wagonType.supportsContainer) || + (loadType === 'BULK' && bulkCargoAllows(allocs, wagonType.id)) || supported.length === 0; if (!ok) { throw new BadRequestException( diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 2d8950fb4..b5a14f6f3 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -121,8 +121,46 @@ export class WagonsService { * (yard workspace, coupling pickers) walk the pages client-side — see * `wagonService.listAll` in the backoffice. */ - findAll(query: ListWagonsQueryDto = {}): Promise> { - return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); + async findAll(query: ListWagonsQueryDto = {}): Promise> { + const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); + await this.attachStatusDates(page.items); + return page; + } + + /** + * Latest status-flip dates from the audit log, for the wagons desk columns: + * when the wagon last went to MAINTENANCE and when it last became AVAILABLE. + * One grouped query per page; null when the log has no such flip. + */ + private async attachStatusDates(wagons: Wagon[]): Promise { + if (!wagons.length) return; + const rows: Array<{ + wagonId: string; + lastMaintenanceAt: Date | null; + lastAvailableAt: Date | null; + }> = await this.dataSource + .getRepository(WagonStatusLog) + .createQueryBuilder('l') + .select('l.wagon_id', 'wagonId') + .addSelect( + `MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Maintenance}')`, + 'lastMaintenanceAt', + ) + .addSelect( + `MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Available}')`, + 'lastAvailableAt', + ) + .where('l.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) }) + .groupBy('l.wagon_id') + .getRawMany(); + const byId = new Map(rows.map((r) => [r.wagonId, r])); + for (const w of wagons) { + const r = byId.get(w.id); + Object.assign(w, { + lastMaintenanceAt: r?.lastMaintenanceAt ?? null, + lastAvailableAt: r?.lastAvailableAt ?? null, + }); + } } async findById(id: string): Promise { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 3fde18648..380a62f02 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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: { @@ -2405,10 +2414,14 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + // Manual settlement (bank transfer / counter) of USD and ETB invoices. + FREIGHT_PERMS.invoices.confirmOffline, // 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 @@ -2525,6 +2538,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. diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 9ac730bda..c131af4bd 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -315,7 +315,7 @@ const App = () => { } /> {/* Merged Invoices / Payments / USD Payments hub — tabs switch via - ?tab=invoices|payments|usd-payments (default invoices). Access is + ?tab=invoices|payments|manual-payments (default invoices). Access is OR'd across both keys so a user with just one still gets in; each tab hides itself if the user lacks the permission it used to be routed on. */} @@ -352,7 +352,7 @@ const App = () => { /> } + element={} /> 0 || Number(c.reeferQuantity ?? 0) > 0, ); + const isBulk = booking.freightType === "BULK"; + // Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers: + // the freight kind, with the shipper's own description alongside. + const cargoHeadline = isBulk + ? (booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo") + : "Containers"; + const cargoDescription = booking.cargoFreeText?.trim() || null; + return ( + + + {cargoHeadline} + + + {isBulk ? "Bulk" : "Container"} + + {cargoDescription ? ( + + — {cargoDescription} + + ) : null} + {items != null && } diff --git a/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx index ba507513c..c668c0570 100644 --- a/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx @@ -1,11 +1,30 @@ -import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useState } from "react"; +import { + Alert, + Badge, + Button, + Card, + Group, + Modal, + NumberInput, + Radio, + Select, + SimpleGrid, + Stack, + Table, + Text, + Textarea, + TextInput, +} from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react"; +import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; -import type { EimsInvoiceStatus } from "@/types/eims"; +import { eimsService } from "@/services/eims.service"; +import { openPdfBlob } from "@/components/warehouses/pdf"; +import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims"; import { useToast } from "@/hooks/use-toast"; const STATUS_COLOR: Record = { @@ -14,6 +33,7 @@ const STATUS_COLOR: Record = { REGISTERED: "edr-green", FAILED: "red", UNKNOWN: "orange", + CANCELLED: "gray", }; const STATUS_LABEL: Record = { @@ -22,6 +42,7 @@ const STATUS_LABEL: Record = { REGISTERED: "Filed", FAILED: "Rejected", UNKNOWN: "Unacknowledged", + CANCELLED: "Cancelled", }; function Field({ label, value }: { label: string; value?: string | number | null }) { @@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null ); } +/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */ +const CANCEL_REASON_CODES = [ + { value: "1", label: "1 — Duplicate" }, + { value: "2", label: "2 — Buyer request" }, + { value: "3", label: "3 — Data entry error" }, + { value: "6", label: "6 — Calculation error" }, +]; + +function CancelModal({ + invoiceId, + opened, + onClose, +}: { + invoiceId: string; + opened: boolean; + onClose: () => void; +}) { + const { toast } = useToast(); + const [reasonCode, setReasonCode] = useState(null); + const [remark, setRemark] = useState(""); + + const cancel = useMutation( + api.invoices.eimsCancel.mutationOptions({ + onSuccess: () => { + onClose(); + toast({ title: "Cancelled with MoR" }); + }, + onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }), + }), + ); + + return ( + + + + Cancels this invoice's registered document at MoR. Irreversible — an already-cancelled + invoice refuses a second attempt. + +