diff --git a/.guide-shots5/00-invoice-detail-pay.png b/.guide-shots5/00-invoice-detail-pay.png deleted file mode 100644 index e1139281a..000000000 Binary files a/.guide-shots5/00-invoice-detail-pay.png and /dev/null differ diff --git a/.guide-shots5/01-invoices-due-full.png b/.guide-shots5/01-invoices-due-full.png deleted file mode 100644 index 2fe0f68dc..000000000 Binary files a/.guide-shots5/01-invoices-due-full.png and /dev/null differ diff --git a/.guide-shots5/02-login-full.png b/.guide-shots5/02-login-full.png deleted file mode 100644 index f0820e0a8..000000000 Binary files a/.guide-shots5/02-login-full.png and /dev/null differ diff --git a/.guide-shots5/03-hager-invoices-empty-full.png b/.guide-shots5/03-hager-invoices-empty-full.png deleted file mode 100644 index 7766ce694..000000000 Binary files a/.guide-shots5/03-hager-invoices-empty-full.png and /dev/null differ diff --git a/.guide-shots5/04-contracts-list-full.png b/.guide-shots5/04-contracts-list-full.png deleted file mode 100644 index 1b5aff47d..000000000 Binary files a/.guide-shots5/04-contracts-list-full.png and /dev/null differ diff --git a/.guide-shots5/05-home-full.png b/.guide-shots5/05-home-full.png deleted file mode 100644 index ce616c2e9..000000000 Binary files a/.guide-shots5/05-home-full.png and /dev/null differ diff --git a/.guide-shots5/06-contract-detail-signed-full.png b/.guide-shots5/06-contract-detail-signed-full.png deleted file mode 100644 index 324c5dbff..000000000 Binary files a/.guide-shots5/06-contract-detail-signed-full.png and /dev/null differ diff --git a/.guide-shots5/07-contract-step1-full.png b/.guide-shots5/07-contract-step1-full.png deleted file mode 100644 index 21dac672f..000000000 Binary files a/.guide-shots5/07-contract-step1-full.png and /dev/null differ diff --git a/.guide-shots5/08-contract-step1-filled-full.png b/.guide-shots5/08-contract-step1-filled-full.png deleted file mode 100644 index f1ebd1632..000000000 Binary files a/.guide-shots5/08-contract-step1-filled-full.png and /dev/null differ diff --git a/.guide-shots5/09-contract-step2-full.png b/.guide-shots5/09-contract-step2-full.png deleted file mode 100644 index ec3b41e85..000000000 Binary files a/.guide-shots5/09-contract-step2-full.png and /dev/null differ diff --git a/.guide-shots5/10-contract-step3-review-full.png b/.guide-shots5/10-contract-step3-review-full.png deleted file mode 100644 index 9dd7c808e..000000000 Binary files a/.guide-shots5/10-contract-step3-review-full.png and /dev/null differ diff --git a/.guide-shots5/11-contract-duplicate-blocked-full.png b/.guide-shots5/11-contract-duplicate-blocked-full.png deleted file mode 100644 index 08b4c7458..000000000 Binary files a/.guide-shots5/11-contract-duplicate-blocked-full.png and /dev/null differ diff --git a/.guide-shots5/11b-quotation-approve-full.png b/.guide-shots5/11b-quotation-approve-full.png deleted file mode 100644 index 08b4c7458..000000000 Binary files a/.guide-shots5/11b-quotation-approve-full.png and /dev/null differ diff --git a/.guide-shots5/12-contract-submitted-list-full.png b/.guide-shots5/12-contract-submitted-list-full.png deleted file mode 100644 index 1e3d403c1..000000000 Binary files a/.guide-shots5/12-contract-submitted-list-full.png and /dev/null differ diff --git a/.guide-shots5/13-bookings-list-full.png b/.guide-shots5/13-bookings-list-full.png deleted file mode 100644 index 452926980..000000000 Binary files a/.guide-shots5/13-bookings-list-full.png and /dev/null differ diff --git a/.guide-shots5/14-booking-documents-modal-full.png b/.guide-shots5/14-booking-documents-modal-full.png deleted file mode 100644 index 28e7b7923..000000000 Binary files a/.guide-shots5/14-booking-documents-modal-full.png and /dev/null differ diff --git a/EDR-Freight-Priority-Flows-Portal.pdf b/EDR-Freight-Priority-Flows-Portal.pdf index a584c1aa9..fe37cc9e8 100644 Binary files a/EDR-Freight-Priority-Flows-Portal.pdf and b/EDR-Freight-Priority-Flows-Portal.pdf differ diff --git a/INV-20260812-00005-QR.png b/INV-20260812-00005-QR.png new file mode 100644 index 000000000..ec1e728d4 Binary files /dev/null and b/INV-20260812-00005-QR.png differ diff --git a/INV-20260812-00005.pdf b/INV-20260812-00005.pdf new file mode 100644 index 000000000..b36573dd2 Binary files /dev/null and b/INV-20260812-00005.pdf differ 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 a3103ca1c..bcc7fcfc3 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 @@ -86,17 +86,16 @@ describe("toEimsInvoice", () => { expect(doc.SellerDetails).toBe(seller); }); - it("maps the buyer from the company row and leaves unmodelled fields null", () => { + it("maps the buyer from the company row and omits Id fields for a TIN-identified buyer", () => { const doc = toEimsInvoice(invoice(), seller, context()); + // MoR rule 7004 rejects an explicit IdType/IdNumber null — the keys must be absent. expect(doc.BuyerDetails).toEqual({ // Resolved by the registration service before the counter was reserved; the mapper copies. City: "31", Country: "70", Email: "buyer@abc.et", HouseNumber: "NEW", - IdNumber: null, - IdType: null, Tin: "0999930000", LegalName: "ABC Trading PLC", Phone: "0912345678", 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 c1ae4c514..0b9eb9e29 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 @@ -35,8 +35,9 @@ export interface EimsBuyerDetails { City: string | null; Email: string | null; HouseNumber: string | null; - IdNumber: string | null; - IdType: string | null; + /** Omitted entirely for a TIN-identified buyer — MoR rule 7004 rejects an explicit null. */ + IdNumber?: string; + IdType?: string; Tin: string; LegalName: string; Phone: string | null; @@ -410,8 +411,8 @@ export function toEimsInvoice( City: context.buyerGeo.City, Email: company.email ?? null, HouseNumber: company.houseNo ?? null, - IdNumber: context.buyerIdNumber ?? null, - IdType: context.buyerIdType ?? null, + ...(context.buyerIdNumber != null ? { IdNumber: context.buyerIdNumber } : {}), + ...(context.buyerIdType != null ? { IdType: context.buyerIdType } : {}), Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, diff --git a/apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts index 70681f814..1d3efdf60 100644 --- a/apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts +++ b/apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApiPropertyOptional } from "@nestjs/swagger"; import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator"; import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types"; @@ -9,9 +9,15 @@ import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types"; * guessed (payment method, collector, provider references — none of it is modelled on `Invoice`). */ export class RegisterSalesReceiptDto { - @ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." }) + @ApiPropertyOptional({ + enum: EIMS_MODE_OF_PAYMENT, + description: + "MoR's confirmed ModeOfPayment enum. Optional when the invoice's recorded payment method " + + "maps unambiguously (CASH, CHEQUE, CPO, CARD, BANK_TRANSFER); otherwise required.", + }) + @IsOptional() @IsIn(EIMS_MODE_OF_PAYMENT) - modeOfPayment!: EimsModeOfPayment; + modeOfPayment?: EimsModeOfPayment; @ApiPropertyOptional({ description: 'Defaults to "Payment received".' }) @IsOptional() 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 93f50e827..55a38480b 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 @@ -116,6 +116,47 @@ describe("EimsReceiptService.registerSalesReceipt", () => { expect(receipt.qr).toBe("iVBORw0KGgo..."); }); + it("derives mode/date/voucher/amount from the recorded manual payment", async () => { + const db = new FakeDb([ + invoiceRow({ + payments: [ + { amount: 4000, method: "CASH", reference: "CRV-000123", paidAt: "2026-08-20T09:00:00.000Z", metadata: null }, + ], + } as never), + ]); + const postBearer = jest.fn().mockResolvedValue(okResponse()); + + await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {} as never); + + const request = postBearer.mock.calls[0][1]; + expect(request.TransactionDetails.ModeOfPayment).toBe("CASH"); + expect(request.ManualReceiptNumber).toBe("CRV-000123"); + expect(request.ReceiptDate).toBe("2026-08-20T09:00:00.000Z"); + expect(request.CollectedAmount).toBe(4000); + }); + + it("puts a gateway reference in TransactionNumber, never ManualReceiptNumber, and demands an explicit mode", async () => { + const db = new FakeDb([ + invoiceRow({ + payments: [ + { amount: 10000, method: "GATEWAY", reference: "txn-9f8e7d", paidAt: "2026-08-21T10:00:00.000Z", metadata: null }, + ], + } as never), + ]); + const postBearer = jest.fn().mockResolvedValue(okResponse()); + const service = build(db, postBearer); + + // GATEWAY says nothing about the channel — deriving would guess a tax field. + await expect(service.registerSalesReceipt(INVOICE_ID, {} as never)).rejects.toThrow( + BadRequestException, + ); + + await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "Card" } as never); + const request = postBearer.mock.calls[0][1]; + expect(request.TransactionDetails.TransactionNumber).toBe("txn-9f8e7d"); + expect(request.ManualReceiptNumber).not.toBe("txn-9f8e7d"); + }); + it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => { const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]); const postBearer = jest.fn().mockResolvedValue(okResponse()); 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 2bdb6cbe2..f2de2937c 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 @@ -17,6 +17,8 @@ import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { + EIMS_MODE_OF_PAYMENT, + EimsModeOfPayment, EimsReceiptResponse, EimsSalesReceiptRequest, EimsWithholdReceiptRequest, @@ -41,8 +43,11 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU * double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution * applies as an unacknowledged registration: a human must check the MoR portal first. * - * Several request fields have no confirmed source in this codebase (payment method, collector, - * withholding rate/amount) and are never guessed — see the two DTOs. + * Sales receipts derive what the invoice's payment ledger actually records — amount, date, + * finance's voucher number (ManualReceiptNumber), gateway transaction id, and the payment mode + * where the ledger method maps unambiguously to MoR's enum. Fields with no recorded source + * (collector, withholding rate/amount, mobile-money modes) are still asked of the caller, never + * guessed — see the two DTOs. */ @Injectable() export class EimsReceiptService { @@ -68,7 +73,26 @@ export class EimsReceiptService { const session = await this.auth.getSessionContext(); const receiptNumber = this.generateReceiptNumber(invoice); - const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount); + + // The newest ledger entry is the payment this receipt vouches for. A gateway settlement's + // `reference` is the provider transaction id; a manual settlement's `reference` is finance's + // own voucher number (CRV) — that one belongs in ManualReceiptNumber so the registered + // receipt matches finance's books. + const lastPayment = invoice.payments?.length + ? invoice.payments[invoice.payments.length - 1] + : null; + const isGateway = (lastPayment?.method ?? "").toUpperCase() === "GATEWAY"; + const modeOfPayment = dto.modeOfPayment ?? deriveModeOfPayment(lastPayment?.method); + if (!modeOfPayment) { + throw new BadRequestException({ + code: "EIMS_MODE_OF_PAYMENT_REQUIRED", + message: + `Recorded payment method "${lastPayment?.method ?? "none"}" has no unambiguous MoR ` + + `ModeOfPayment — pass modeOfPayment (one of ${EIMS_MODE_OF_PAYMENT.join(", ")}).`, + }); + } + const collectedAmount = + dto.collectedAmount ?? (lastPayment ? lastPayment.amount : Number(invoice.paidAmount)); const balance = Number(invoice.balanceAmount); const request: EimsSalesReceiptRequest = { @@ -77,9 +101,9 @@ export class EimsReceiptService { Reason: dto.reason ?? "Payment received", // ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema // error for this field was ever observed to confirm which form MoR actually requires. - ReceiptDate: new Date().toISOString(), + ReceiptDate: lastPayment?.paidAt ?? new Date().toISOString(), ReceiptCounter: String(Date.now()), - ManualReceiptNumber: receiptNumber, + ManualReceiptNumber: (!isGateway && lastPayment?.reference) || receiptNumber, SourceSystemType: session.systemType, SourceSystemNumber: session.systemNumber, ReceiptCurrency: currency, @@ -97,7 +121,7 @@ export class EimsReceiptService { }, ], TransactionDetails: { - ModeOfPayment: dto.modeOfPayment, + ModeOfPayment: modeOfPayment, ChequeNumber: dto.chequeNumber ?? null, CPONumber: dto.cpoNumber ?? null, DocumentNumber: dto.documentNumber ?? null, @@ -105,7 +129,7 @@ export class EimsReceiptService { PaymentServiceProvider: dto.paymentServiceProvider ?? null, OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null, AccountNumber: dto.accountNumber ?? null, - TransactionNumber: dto.transactionNumber ?? null, + TransactionNumber: dto.transactionNumber ?? (isGateway ? (lastPayment?.reference ?? null) : null), }, }; @@ -286,3 +310,17 @@ export class EimsReceiptService { return `REC-${invoice.invoiceNumber}-${Date.now()}`; } } + +/** + * Recorded ledger method → MoR ModeOfPayment, only where the mapping is unambiguous. Mobile-money + * methods (TELEBIRR, EBIRR, …) have no MoR enum slot, and "GATEWAY" says nothing about the real + * channel — those return undefined and the caller must supply modeOfPayment explicitly. Guessing + * a tax field is worse than asking. + */ +function deriveModeOfPayment(method: string | null | undefined): EimsModeOfPayment | undefined { + if (!method) return undefined; + const normalized = method.toUpperCase().replace(/-/g, "_"); + const direct = EIMS_MODE_OF_PAYMENT.find((m) => m.toUpperCase().replace(/ /g, "_") === normalized); + if (direct) return direct; + return normalized === "BANK_TRANSFER" ? "Local Bank Transfer" : undefined; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 8a348e625..625ccfda7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -571,6 +571,12 @@ export class BookingBatchService implements OnModuleInit { relations: { company: true }, }); if (!booking) return; + // A dead booking keeps payment_status = 'PAID' (it was paid before it died), + // so every rescue path below would happily re-place and re-allocate it — + // that is how a cancelled consolidation-lapse booking came back onto its + // train 30s after being cancelled. Never resurrect a dead booking. + if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status)) + return; if (!booking.trainScheduleId) { // A paid booking with no train is money taken and nothing boarding. The // hold was expired before the payment landed (webhook lag beat the