diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index da0b1ceb9..2c636eee4 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -76,6 +76,12 @@ SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false +# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key). +# Unset = every employee. A key matching no organization returns no users. +# Dev seed key: edr_freight +# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001 +FREIGHT_ORG_KEY=edr_freight + # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost MINIO_PORT=9000 @@ -166,19 +172,29 @@ EIMS_SELLER_LOCALITY= # Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails # locally, naming the missing variables, until these are set. -EIMS_TAX_CODE=0 +# Required, and deliberately unset: the choice is a tax position, not a default. +# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH +# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt). +EIMS_TAX_CODE= EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 # Document classification and payment presentation. EIMS_TRANSACTION_TYPE=B2B -EIMS_NATURE_OF_SUPPLIES=Service +# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'. +EIMS_NATURE_OF_SUPPLIES=service EIMS_PAYMENT_MODE=CASH EIMS_PAYMENT_TERM=IMMIDIATE EIMS_UNIT_DEFAULT=PCS # MoR numeric country code for the buyer; our companies store the country name. EIMS_BUYER_COUNTRY_CODE= +# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$. +# An unmapped region fails locally rather than being filed with a guess. +EIMS_BUYER_REGION_CODES=Addis Ababa=13 +# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is +# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess. +EIMS_BUYER_WEREDA_CODES= EIMS_CASHIER_NAME= EIMS_SALESPERSON_NAME= # Automatic filing of issued invoices (@Cron sweep, one invoice per tick). diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 6eaaf8007..a8a929ca5 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -81,6 +81,14 @@ export interface EimsInvoiceConfig { paymentTerm: string; unitDefault: string; buyerCountryCode: string | null; + /** + * 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 + * locally rather than being filed with a guessed one. + */ + buyerRegionCodes: Record; + /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ + buyerWeredaCodes: Record; cashierName: string | null; salesPersonName: string | null; } @@ -103,6 +111,16 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n return value; }; +/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */ +const parseCodeMap = (raw: string | undefined): Record => { + const map: Record = {}; + for (const pair of (raw ?? "").split(",")) { + const [name, code] = pair.split("="); + if (name?.trim() && code?.trim()) map[name.trim()] = code.trim(); + } + return map; +}; + /** 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; @@ -168,6 +186,8 @@ 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, + buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), + buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, }, diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index d4f0c4039..36e4e34d0 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts new file mode 100644 index 000000000..39a10ca90 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts @@ -0,0 +1,100 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const CONTAINER_CODES = [ + 'IMPORT_CONTAINER_CUSTOMS', + 'IMPORT_CONTAINER_NO_CUSTOMS', + 'EXPORT_CONTAINER_CUSTOMS', + 'EXPORT_CONTAINER_NO_CUSTOMS', + 'INTERCITY_CONTAINER', +]; + +const BULK_CODES = [ + 'IMPORT_BULK_CUSTOMS', + 'IMPORT_BULK_NO_CUSTOMS', + 'EXPORT_BULK_CUSTOMS', + 'EXPORT_BULK_NO_CUSTOMS', + 'INTERCITY_BULK', +]; + +/** + * Bulk contract templates become staff-created, keyed by (cargo type, customs + * clearing) instead of the fixed direction codes. The five container templates + * stay seeded and become undeletable system rows; the five seeded bulk rows are + * retired (soft-deleted). cargo_types gains has_contract_template, marking + * which bulk commodities may carry their own template. + */ +export class BulkContractTemplates3320000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id), + ADD COLUMN IF NOT EXISTS with_customs boolean, + ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false + `); + + // Generated bulk codes (BULK__NO_CUSTOMS) outgrow varchar(40). + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ALTER COLUMN code TYPE varchar(80) + `); + + await queryRunner.query( + `UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`, + [CONTAINER_CODES], + ); + + // Retire the fixed bulk templates; staff recreate them per cargo type. + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = now() + WHERE code = ANY($1) AND deleted_at IS NULL`, + [BULK_CODES], + ); + + // Code stays unique among live rows only, so a deleted combo can be + // recreated under the same generated code. + await queryRunner.query( + `ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code + ON freight.contract_templates (code) WHERE deleted_at IS NULL + `); + + // One template per (bulk cargo type, customs option) — the "same + // combination" rule, enforced even under concurrent creates. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs + ON freight.contract_templates (cargo_type_id, with_customs) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT uq_contract_templates_code UNIQUE (code) + `); + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`, + [BULK_CODES], + ); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP COLUMN IF EXISTS cargo_type_id, + DROP COLUMN IF EXISTS with_customs, + DROP COLUMN IF EXISTS is_system + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts similarity index 98% rename from apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts rename to apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts index c80dfcd1e..1ff9bab35 100644 --- a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts +++ b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts @@ -24,7 +24,7 @@ import { MigrationInterface, QueryRunner } from "typeorm"; * ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored * verbatim so a compliance value is never mangled by a parse. */ -export class EimsInvoiceRegistration3300000000000 implements MigrationInterface { +export class EimsInvoiceRegistration3330000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE freight.invoices diff --git a/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts new file mode 100644 index 000000000..481b7489c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * EIMS document numbering. + * + * MoR validates `DocumentDetails.DocumentNumber` against `^(0|[1-9][0-9]{0,8})$` — a plain integer + * of at most nine digits. Our own `INV-YYYYMMDD-NNNNN` can therefore never be sent, so EIMS needs + * its own sequence, allocated from the same locked state row as the invoice counter and recorded + * on the invoice so a filed document can be traced back to it. + */ +export class EimsDocumentNumberSequence3340000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ADD COLUMN IF NOT EXISTS next_document_number bigint NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS in_flight_document_number bigint + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_document_number varchar(16) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices DROP COLUMN IF EXISTS eims_document_number + `); + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + DROP COLUMN IF EXISTS next_document_number, + DROP COLUMN IF EXISTS in_flight_document_number + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/list-users.service.ts b/apps/edr-freight-api/src/modules/auth/list-users.service.ts index cf7e22be2..c598e105a 100644 --- a/apps/edr-freight-api/src/modules/auth/list-users.service.ts +++ b/apps/edr-freight-api/src/modules/auth/list-users.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { PaginatedResponse } from '@edr/types'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; @@ -19,6 +20,7 @@ import { paginateQuery } from '../../common/utils/pagination.util'; export class ListUsersService { constructor( @InjectRepository(User) private readonly users: Repository, + private readonly config: ConfigService, ) {} findAll(query: ListUsersQueryDto): Promise> { @@ -40,6 +42,29 @@ export class ListUsersService { ]) .orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC'); + // Restrict to one IAM organization when configured. The org key differs per + // environment (dev seeds `edr_freight`, production uses the registered + // company key), so this is config rather than a constant. An unset key + // means no restriction; a key matching no organization matches no user — + // failing closed rather than silently widening to every org. + const orgKey = this.config.get('FREIGHT_ORG_KEY'); + if (orgKey) { + // EXISTS, not a join: a user with several employee rows would otherwise + // be returned once per row, duplicating them in the list and inflating + // `getManyAndCount`'s total. + qb.andWhere( + `EXISTS ( + SELECT 1 + FROM iam.employees emp + JOIN iam.organizations org ON org.id = emp.organization_id + WHERE emp.user_id = "user".id + AND org.key = :orgKey + AND org.deleted_at IS NULL + )`, + { orgKey }, + ); + } + if (query.userType) { qb.andWhere('user.userType = :userType', { userType: query.userType }); } 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 aef4ac163..fa0876310 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,6 +60,8 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, + buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: {}, ...over, }); @@ -130,7 +132,7 @@ describe("toEimsInvoice", () => { ExciseTaxValue: 0, TotalLineAmount: 11500, Unit: "PCS", - NatureOfSupplies: "Service", + NatureOfSupplies: "service", HarmonizationCode: null, }); expect(doc.ItemList[1]).toMatchObject({ @@ -207,6 +209,77 @@ describe("toEimsInvoice", () => { }); }); +describe("toEimsInvoice — MoR field constraints", () => { + it("passes a buyer region through when it is already a MoR code", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + expect(doc.BuyerDetails.Region).toBe("13"); + }); + + it("maps a region name to its code, ignoring case and spacing", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, region: " addis ababa " } }), + seller, + context({ buyerRegionCodes: { "Addis Ababa": "13" } }), + ); + expect(doc.BuyerDetails.Region).toBe("13"); + }); + + it("refuses to file a buyer whose region has no mapping", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }), + seller, + context(), + ), + ).toThrow(/not a MoR Region code and has no mapping/); + }); + + it("refuses a buyer with no region at all rather than guessing one", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, region: null } }), + seller, + context(), + ), + ).toThrow(/buyer Region \(unset\)/); + }); + + it("passes a buyer wereda through when it is already a MoR code", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + expect(doc.BuyerDetails.Wereda).toBe("574"); + }); + + it("maps a wereda name to its code", () => { + const doc = toEimsInvoice( + invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), + seller, + context({ buyerWeredaCodes: { Yeka: "99" } }), + ); + expect(doc.BuyerDetails.Wereda).toBe("99"); + }); + + it("refuses to file a buyer whose wereda has no mapping", () => { + expect(() => + toEimsInvoice( + invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), + seller, + context({ buyerWeredaCodes: {} }), + ), + ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_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"); + }); + + it("rejects a NatureOfSupplies MoR does not accept", () => { + expect(() => + toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })), + ).toThrow(/must be one of goods, service/); + }); +}); + describe("formatEimsDate", () => { it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => { expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00"); 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 864d9f829..0c4b40829 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 @@ -210,6 +210,22 @@ export interface EimsMapperContext { relatedDocument?: string | null; /** MoR numeric country code for the buyer; our DB stores the country name. */ buyerCountryCode?: string | null; + /** + * Region name → MoR numeric code, for buyers whose stored region is free text. + * + * `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region` + * against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else + * must be in this map or the mapping **fails locally** — sending a guessed region code onto a + * tax document is worse than refusing to file. + */ + buyerRegionCodes: Record; + /** + * Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names + * ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an + * error, so this is precautionary rather than confirmed — but the fix is identical either way: + * fail locally on an unmapped name rather than file a guess. + */ + buyerWeredaCodes: Record; buyerIdType?: string | null; buyerIdNumber?: string | null; buyerCity?: string | null; @@ -220,6 +236,22 @@ export interface EimsMapperContext { formatDate?: (issuedAt: Date) => string; } +/** + * MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused + * as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller + * "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex + * the way it named Region's. + */ +const LOCATION_CODE = /^[0-9]{1,3}$/; + +/** + * The only two values MoR accepts for `NatureOfSupplies`, lowercase. + * + * Its schema branches on this as a `oneOf` with a `const` per branch, so `"Service"` fails the + * whole `ItemList` — the error reads "must be the constant value 'service'". + */ +const NATURE_OF_SUPPLIES = ["goods", "service"] as const; + const num = (v: number | string): number => { const n = Number(v); if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`); @@ -240,6 +272,33 @@ export const formatEimsDate = (issuedAt: Date): string => * an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no * 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. + */ +function resolveLocationCode( + field: "Region" | "Wereda", + value: string | null | undefined, + codes: Record, + envVar: string, + invoiceNumber: string, +): string { + 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]; + if (mapped && LOCATION_CODE.test(mapped)) return mapped; + + 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}.`, + ); +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -266,6 +325,14 @@ export function toEimsInvoice( throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); } + const natureOfSupplies = context.natureOfSupplies.trim().toLowerCase(); + if (!NATURE_OF_SUPPLIES.includes(natureOfSupplies as (typeof NATURE_OF_SUPPLIES)[number])) { + throw new Error( + `EIMS mapping: NatureOfSupplies must be one of ${NATURE_OF_SUPPLIES.join(", ")}, ` + + `got "${context.natureOfSupplies}"`, + ); + } + const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => { const lineNumber = index + 1; const tax = context.taxForLine(line, lineNumber); @@ -285,7 +352,7 @@ export function toEimsInvoice( Discount: 0, ExciseTaxValue, HarmonizationCode: null, - NatureOfSupplies: context.natureOfSupplies, + NatureOfSupplies: natureOfSupplies, ItemCode: line.chargeType, ProductDescription: line.description?.trim() || line.chargeType, PreTaxValue, @@ -329,12 +396,24 @@ export function toEimsInvoice( Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, - Region: company.region ?? null, + Region: resolveLocationCode( + "Region", + company.region, + context.buyerRegionCodes, + "EIMS_BUYER_REGION_CODES", + invoice.invoiceNumber, + ), Country: context.buyerCountryCode ?? null, Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, - Wereda: company.woreda ?? null, + Wereda: resolveLocationCode( + "Wereda", + company.woreda, + context.buyerWeredaCodes, + "EIMS_BUYER_WEREDA_CODES", + invoice.invoiceNumber, + ), }, DocumentDetails: { DocumentNumber: context.documentNumber, 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 c5c000943..411d15ebe 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 @@ -115,6 +115,10 @@ export class Invoice extends BaseEntity { @Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true }) eimsIrn?: string | null; + /** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */ + @Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true }) + eimsDocumentNumber?: string | null; + /** The `SourceSystem.InvoiceCounter` this invoice consumed. */ @Column({ name: "eims_invoice_counter", type: "bigint", nullable: true }) eimsInvoiceCounter?: number | null; diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts index 6cba17f61..7053d224f 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts @@ -3,6 +3,8 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, Patch, Post, @@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ContractTemplatesService } from "./contract-templates.service"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticlesDto, UpdateArticleDto, UpdateContractTemplateDto, } from "./dto/contract-template.dto"; +// `view` opens the Templates page; `read` is API-read-only for other pages +// that show template data; create/update/delete gate each write. `manage` is +// the legacy write key and keeps working for roles that already hold it. +const TEMPLATE_READ = [ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.read, + FREIGHT_PERMS.settings.contractTemplates.update, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, +]; + +const TEMPLATE_UPDATE = [ + FREIGHT_PERMS.settings.contractTemplates.update, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, +]; + @ApiTags("contract-templates") @Controller("contract-templates") export class ContractTemplatesController { constructor(private readonly service: ContractTemplatesService) {} - // Reads are staff-only (the backoffice Templates tab is the only consumer); - // writes are admin-guarded like other freight configuration resources. - @Get() - @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, - FREIGHT_PERMS.admin, - ]) - @ApiOperation({ summary: "List the six contract document templates" }) + @BookingStaff(TEMPLATE_READ) + @ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" }) list() { return this.service.list(); } - @Get(":code") + @Post() @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.create, FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin, ]) + @ApiOperation({ + summary: + "Create a bulk contract template for a (cargo type, customs option) pair", + }) + create(@Body() dto: CreateContractTemplateDto) { + return this.service.create(dto); + } + + @Get(":code") + @BookingStaff(TEMPLATE_READ) @ApiOperation({ summary: "Get one contract template by code" }) getByCode(@Param("code") code: string) { return this.service.getByCode(code); } @Patch(":code") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" }) update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { return this.service.update(code, dto); } - @Post(":code/preview") + @Delete(":code") @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.settings.contractTemplates.delete, FREIGHT_PERMS.admin, ]) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: "Delete a staff-created bulk template (system templates refuse)", + }) + remove(@Param("code") code: string) { + return this.service.remove(code); + } + + @Post(":code/preview") + @BookingStaff(TEMPLATE_READ) @ApiOperation({ summary: "Render an HTML preview of the template against mock contract data", }) @@ -77,21 +109,21 @@ export class ContractTemplatesController { /* ------------------------- article routes ------------------------- */ @Put(":code/articles") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" }) replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { return this.service.replaceArticles(code, dto.articles); } @Post(":code/articles") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Add an article to the template" }) addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { return this.service.addArticle(code, dto); } @Patch(":code/articles/:articleId") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Update an article's title or body" }) updateArticle( @Param("code") code: string, @@ -102,7 +134,7 @@ export class ContractTemplatesController { } @Delete(":code/articles/:articleId") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Remove an article from the template" }) removeArticle( @Param("code") code: string, diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts index 2f4fb0117..9d50b1677 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; -import { - ContractTemplate, - ContractTemplateCode, -} from "./entities/contract-template.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContractTemplate } from "./entities/contract-template.entity"; @Injectable() export class ContractTemplatesRepository extends BaseRepository { @@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository { + findByCode(code: string): Promise { return this.repository.findOne({ where: { code } }); } override findAll(): Promise { - return this.repository.find({ order: { code: "ASC" } }); + return this.repository.find({ + relations: { cargoType: true }, + order: { code: "ASC" }, + }); + } + + findByCargoCombo( + cargoTypeId: string, + withCustoms: boolean, + ): Promise { + return this.repository.findOne({ where: { cargoTypeId, withCustoms } }); + } + + /** + * The active bulk template covering this cargo type: written against the + * cargo type itself or against its parent group (the two are mutually + * exclusive, so at most one row matches). + */ + findActiveBulkTemplate( + cargoTypeId: string, + withCustoms: boolean, + ): Promise { + return this.repository + .createQueryBuilder("t") + .where("t.is_active = true") + .andWhere("t.with_customs = :withCustoms", { withCustoms }) + .andWhere( + `(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = ( + SELECT c.parent_group_id FROM freight.cargo_types c + WHERE c.id = :cargoTypeId AND c.deleted_at IS NULL + ))`, + { cargoTypeId }, + ) + .getOne(); + } + + findCargoType(id: string): Promise { + return this.repository.manager + .getRepository(CargoType) + .findOne({ where: { id } }); } async saveTemplate(template: ContractTemplate): Promise { diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index ea41b57a2..da9c849f7 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { randomUUID } from "node:crypto"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; @@ -11,6 +16,7 @@ import { import { ContractTemplatesRepository } from "./contract-templates.repository"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticleDto, UpdateArticleDto, @@ -53,13 +59,17 @@ export class ContractTemplatesService { async list(): Promise { const templates = await this.repository.findAll(); const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const)); - return templates.sort( - (a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99), - ); + // Seeded container templates first in canonical order, then staff-created + // bulk templates alphabetically. + return templates.sort((a, b) => { + const ra = rank.get(a.code as ContractTemplateCode) ?? 99; + const rb = rank.get(b.code as ContractTemplateCode) ?? 99; + return ra !== rb ? ra - rb : a.name.localeCompare(b.name); + }); } async getByCode(code: string): Promise { - const template = await this.repository.findByCode(this.assertCode(code)); + const template = await this.repository.findByCode(code?.toUpperCase() ?? ""); if (!template) { throw new NotFoundException(`Contract template ${code} not found`); } @@ -67,15 +77,93 @@ export class ContractTemplatesService { } /** - * The active template used when generating a contract document for the given - * direction/freight/customs triple; null when missing or deactivated (the - * renderer then falls back to the built-in generic layout). + * Staff-created bulk template for one (cargo type, customs option) pair. + * The cargo type must have hasContractTemplate enabled and the combination + * must not already exist — the same commodity + customs pairing is edited, + * never duplicated. + */ + async create(dto: CreateContractTemplateDto): Promise { + const cargoType = await this.repository.findCargoType(dto.cargoTypeId); + if (!cargoType) { + throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + if (!cargoType.hasContractTemplate) { + throw new BadRequestException( + `"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`, + ); + } + const variant = dto.withCustoms ? "with" : "without"; + const existing = await this.repository.findByCargoCombo( + dto.cargoTypeId, + dto.withCustoms, + ); + if (existing) { + throw new ConflictException( + `A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`, + ); + } + + const template = new ContractTemplate(); + template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase(); + template.name = + dto.name ?? + `${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`; + template.description = dto.description ?? null; + template.documentTitle = dto.withCustoms + ? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services` + : `${cargoType.cargoTypeName} Transportation Services`; + template.whereasClauses = []; + template.articles = []; + template.isActive = true; + template.cargoTypeId = cargoType.id; + template.withCustoms = dto.withCustoms; + template.isSystem = false; + try { + return await this.repository.saveTemplate(template); + } catch (error) { + // Partial unique index backstop for concurrent creates of the same combo. + if ((error as { code?: string })?.code === "23505") { + throw new ConflictException( + `A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`, + ); + } + throw error; + } + } + + /** Bulk templates only — the five seeded container templates are permanent. */ + async remove(code: string): Promise { + const template = await this.getByCode(code); + if (template.isSystem) { + throw new BadRequestException( + "System container templates cannot be deleted", + ); + } + await this.repository.softDelete(template.id); + } + + /** + * The active template used when generating a contract document. Container + * contracts resolve through the fixed direction/customs codes; bulk contracts + * resolve through the staff-created template for the contract's cargo type + * (or its parent group) and customs option. Null when nothing matches or the + * match is deactivated (the renderer then falls back to the built-in generic + * layout). */ async findActiveForContract( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, + cargoTypeId?: string | null, ): Promise { + const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); + if (isBulk) { + if (!cargoTypeId) return null; + return this.repository.findActiveBulkTemplate( + cargoTypeId, + Boolean(customsClearingEnabled), + ); + } const code = contractTemplateCodeFor( tradeDirection, freightType, @@ -180,16 +268,32 @@ export class ContractTemplatesService { : this.sorted(template.articles), }; - const view = this.buildMockView(template.code, dynamicTemplate); + const view = this.buildMockView(template, dynamicTemplate); return { html: this.renderer.render(view) }; } + /** + * Registry key the mock preview renders against. Staff-created bulk + * templates aren't in the fixed code map — they preview against the + * representative bulk import pack matching their customs option. + */ + private previewKeyFor(template: ContractTemplate): string { + if (template.cargoTypeId) { + return template.withCustoms + ? "IMP_BULK_USD_FORWARDING" + : "IMP_BULK_USD_TRANSPORT_ONLY"; + } + return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode]; + } + private buildMockView( - code: ContractTemplateCode, + template: ContractTemplate, dynamicTemplate: ContractDynamicTemplateView, ): ContractViewModel { - const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]); - const isBulk = code.endsWith("_BULK"); + const code = template.code; + const previewKey = this.previewKeyFor(template); + const meta = getTemplateMeta(previewKey); + const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK"); const now = new Date(); // Representative rate schedule so the admin preview shows the live-rate @@ -200,7 +304,7 @@ export class ContractTemplatesService { bookingId: "00000000-0000-0000-0000-000000000000", reference: "EDR/CT/2026/0042", status: "CONTRACT_READY", - templateKey: PREVIEW_TEMPLATE_KEYS[code], + templateKey: previewKey, template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" }, contractDate: now.toLocaleDateString("en-GB", { day: "numeric", @@ -275,7 +379,7 @@ export class ContractTemplatesService { } /** Static, representative rate schedule for the admin preview only. */ - private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { + private mockRateSchedule(code: string, isBulk: boolean): RateSchedule { const dir = code.startsWith("IMPORT") ? "import" : code.startsWith("EXPORT") @@ -311,16 +415,6 @@ export class ContractTemplatesService { }; } - private assertCode(code: string): ContractTemplateCode { - const upper = code?.toUpperCase() as ContractTemplateCode; - if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { - throw new BadRequestException( - `Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`, - ); - } - return upper; - } - private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts index 0ea69f262..c8911d8e5 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -6,12 +6,41 @@ import { IsInt, IsOptional, IsString, + IsUUID, MaxLength, Min, MinLength, ValidateNested, } from "class-validator"; +export class CreateContractTemplateDto { + @ApiProperty({ + description: + "Bulk cargo type this template is written for (must have hasContractTemplate enabled)", + format: "uuid", + }) + @IsUUID() + cargoTypeId!: string; + + @ApiProperty({ + description: "Whether this is the with-customs-clearing variant", + }) + @IsBoolean() + withCustoms!: boolean; + + @ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" }) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(200) + name?: string; + + @ApiPropertyOptional({ description: "Short description shown on the template card" }) + @IsOptional() + @IsString() + description?: string; +} + export class UpdateContractTemplateDto { @ApiPropertyOptional({ description: "Display name of the template" }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index c322c20a4..af0721572 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -1,11 +1,18 @@ import { BaseEntity } from "@edr/api-common"; -import { Column, Entity, Index } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; /** - * The ten canonical contract document templates. Import and export split by - * customs clearing (× freight type = 8); intercity does not, because it is a - * purely domestic Ethiopian movement that crosses no border and therefore has - * no customs leg at all (× freight type = 2). + * The five seeded container templates (import/export split by customs + * clearing; intercity is domestic, crosses no border, so it has a single + * template). These are system rows: always present, never deletable. + * + * Bulk templates are NOT seeded — staff create them per bulk cargo type + * (`cargoTypeId`) and customs option (`withCustoms`), one template per + * combination. Their codes are generated as BULK__(NO_)CUSTOMS. + * The retired direction-keyed bulk codes remain listed so old frozen document + * snapshots still label correctly. * * Contracts store DOMESTIC for intercity movements; the template layer labels * those INTERCITY to match the commercial vocabulary used on the printed @@ -76,10 +83,12 @@ export function contractTemplateCodeFor( } @Entity({ schema: "freight", name: "contract_templates" }) -@Index(["code"], { unique: true }) +// Uniqueness lives in partial DB indexes (live rows only): code, and +// (cargo_type_id, with_customs) for staff-created bulk templates. +@Index(["code"]) export class ContractTemplate extends BaseEntity { - @Column({ name: "code", type: "varchar", length: 40, unique: true }) - code!: ContractTemplateCode; + @Column({ name: "code", type: "varchar", length: 80 }) + code!: string; @Column({ name: "name", type: "varchar", length: 200 }) name!: string; @@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity { @Column({ name: "is_active", type: "boolean", default: true }) isActive!: boolean; + + /** Bulk templates only: the cargo type this template is written for. */ + @Column({ name: "cargo_type_id", type: "uuid", nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true }) + @JoinColumn({ name: "cargo_type_id" }) + cargoType?: CargoType | null; + + /** Bulk templates only: whether this is the with-customs-clearing variant. */ + @Column({ name: "with_customs", type: "boolean", nullable: true }) + withCustoms?: boolean | null; + + /** The five seeded container templates — cannot be deleted. */ + @Column({ name: "is_system", type: "boolean", default: false }) + isSystem!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 0a073c9f8..ba3e3fd8b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -424,6 +424,8 @@ export class ContractTransitionService { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); if (!active) return null; return { 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 20ccfdfba..4e7a82069 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 @@ -57,6 +57,37 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void { `(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`, }); } + + assertSellerFormats(config.invoice); +} + +/** + * MoR's own patterns for the seller fields, checked here rather than at the gateway. + * + * A placeholder like `_` is "set" but unfilable, and finding that out costs a real request and a + * consumed counter — these are the exact regexes its 400 SCHEMA ERROR quoted back at us. + */ +const SELLER_FORMATS: { env: string; value: (i: EimsConfig["invoice"]) => string; pattern: RegExp }[] = [ + { env: "EIMS_SELLER_PHONE", value: (i) => i.sellerPhone, pattern: /^\+?[0-9]{6,}$/ }, + { + env: "EIMS_SELLER_EMAIL", + value: (i) => i.sellerEmail, + pattern: /^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/, + }, + { env: "EIMS_SELLER_REGION", value: (i) => i.sellerRegion, pattern: /^[0-9]{1,3}$/ }, + { env: "EIMS_SELLER_WEREDA", value: (i) => i.sellerWereda, pattern: /^[0-9A-Za-z]{1,10}$/ }, +]; + +function assertSellerFormats(invoice: EimsConfig["invoice"]): void { + const bad = SELLER_FORMATS.filter(({ value, pattern }) => !pattern.test(value(invoice))).map( + ({ env, pattern }) => `${env} (must match ${pattern.source})`, + ); + if (bad.length > 0) { + throw new BadRequestException({ + code: "EIMS_INVOICE_CONFIG_INVALID", + message: `EIMS seller details would be rejected by MoR: ${bad.join("; ")}`, + }); + } } export function buildEimsSeller(config: EimsConfig): EimsSellerDetails { @@ -112,6 +143,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, + buyerRegionCodes: invoice.buyerRegionCodes, + buyerWeredaCodes: invoice.buyerWeredaCodes, exchangeRate: input.exchangeRate ?? 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 1035c2b33..66e27da6b 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 @@ -6,6 +6,7 @@ import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; import { eimsInvoiceConfig } from "./eims-test-fixtures"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; @@ -91,9 +92,11 @@ class FakeDb { id: "state-1", systemNumber: SYSTEM_NUMBER, nextInvoiceCounter: 7, + nextDocumentNumber: 5, previousIrn: null, inFlightInvoiceId: null, inFlightCounter: null, + inFlightDocumentNumber: null, blockedReason: null, ...state, } as EimsSystemState; @@ -130,7 +133,10 @@ class FakeDb { return { manager: this.manager, getRepository: this.manager.getRepository, - query: async () => LINES, + query: async (sql: string) => + sql.includes("eims_system_state") + ? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }] + : LINES, transaction: async (body: (m: unknown) => Promise) => { this.onTransaction?.(); return body(this.manager); @@ -147,17 +153,26 @@ const build = ( postSigned: jest.Mock, cfg: EimsConfig = config(), postBearer: jest.Mock = jest.fn(), - getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION), + getSessionContext: jest.Mock | undefined = undefined, + notify: jest.Mock = jest.fn().mockResolvedValue(undefined), ) => new EimsInvoiceRegistrationService( db.asDataSource(), { get: () => cfg } as unknown as ConfigService, { postSigned, postBearer } as unknown as EimsClientService, - { getSessionContext } as unknown as EimsAuthService, + { + getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION), + } as unknown as EimsAuthService, + { notify } as unknown as NotificationInboxService, ); -/** Document number the fixtures register under; `/v1/verify` must echo it back. */ -const DOCUMENT_NUMBER = "INV-20260807-00042"; +/** + * Document number the fixtures register under; `/v1/verify` must echo it back. + * + * A plain integer, not our `invoiceNumber`: MoR validates the field against + * `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above. + */ +const DOCUMENT_NUMBER = "5"; /** * `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase @@ -220,7 +235,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; expect(request.SourceSystem.InvoiceCounter).toBe(42); expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN"); - expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042"); + expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER); expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); @@ -345,7 +360,9 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { inFlightInvoiceId: null, blockedReason: null, previousIrn: null, - nextInvoiceCounter: 8, // consumed: the attempt reached the gateway + // Returned, not consumed: MoR tracks the sequence and rejects a gap + // ("Invoice counter is not correct. expected : 1"). + nextInvoiceCounter: 7, }); }); @@ -391,7 +408,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { expect(postSigned).toHaveBeenCalledTimes(1); }); - it("never reuses a counter once an attempt has begun", async () => { + it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => { const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); const postSigned = jest .fn() @@ -404,8 +421,72 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { ); await service.registerInvoiceWithEims(OTHER_INVOICE_ID); - expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); - expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8); + // The two numbers move differently, because MoR constrains them differently: the counter must + // not skip (it returns), the document number must not repeat (it is burned). + const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest; + expect(first.SourceSystem.InvoiceCounter).toBe(7); + expect(second.SourceSystem.InvoiceCounter).toBe(7); + expect(first.DocumentDetails.DocumentNumber).toBe("5"); + expect(second.DocumentDetails.DocumentNumber).toBe("6"); + }); +}); + +describe("EimsInvoiceRegistrationService staff alerting", () => { + it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => { + const db = new FakeDb([invoiceRow()]); + const notify = jest.fn().mockResolvedValue(undefined); + const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT")); + + await expect( + build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toBeInstanceOf(EimsApiException); + + expect(notify).toHaveBeenCalledTimes(1); + const sent = notify.mock.calls[0][0]; + expect(sent.priority).toBe("HIGH"); + expect(sent.title).toMatch(/blocked/i); + expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve"); + }); + + it("raises a normal-priority alert for a deterministic rejection", async () => { + const db = new FakeDb([invoiceRow()]); + const notify = jest.fn().mockResolvedValue(undefined); + const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406)); + + await expect( + build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toBeInstanceOf(EimsApiException); + + expect(notify.mock.calls[0][0].priority).toBe("NORMAL"); + }); + + it("does not alert on a successful filing", async () => { + const db = new FakeDb([invoiceRow()]); + const notify = jest.fn(); + + await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify) + .registerInvoiceWithEims(INVOICE_ID); + + expect(notify).not.toHaveBeenCalled(); + }); + + it("lets the filing outcome stand even if the alert itself fails", async () => { + const db = new FakeDb([invoiceRow()]); + const notify = jest.fn().mockRejectedValue(new Error("inbox down")); + const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406)); + + await expect( + build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toThrow(/EIMS register failed \(406\)/); + + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed); }); }); @@ -447,7 +528,15 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { const blocked = () => - new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], { + new FakeDb( + [ + invoiceRow({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsInvoiceCounter: 7, + eimsDocumentNumber: DOCUMENT_NUMBER, + }), + ], + { inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8, @@ -498,13 +587,13 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { const db = blocked(); const postBearer = jest.fn().mockResolvedValue( verifyResponse({ - DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" }, + DocumentDetails: { Type: "INV", DocumentNumber: "99999" }, }), ); await expect( build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), - ).rejects.toThrow(/not INV-20260807-00042/); + ).rejects.toThrow(/not 5/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Unknown, @@ -547,7 +636,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses to resolve an invoice that is not the in-flight one", async () => { const db = blocked(); - db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID })); + db.invoices.set( + OTHER_INVOICE_ID, + invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }), + ); const postBearer = jest.fn().mockResolvedValue(verifyResponse()); await expect( 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 4ff9ccfb8..64854bf53 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 @@ -17,6 +17,9 @@ import { EimsMapperLine, toEimsInvoice, } from "../billing/eims-invoice.mapper"; +import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +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"; @@ -44,6 +47,8 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU interface Reservation { stateId: string; invoiceCounter: number; + /** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */ + documentNumber: string; previousIrn: string; } @@ -72,6 +77,7 @@ export class EimsInvoiceRegistrationService { private readonly config: ConfigService, private readonly client: EimsClientService, private readonly auth: EimsAuthService, + private readonly inbox: NotificationInboxService, ) {} private get cfg(): EimsConfig { @@ -98,8 +104,9 @@ export class EimsInvoiceRegistrationService { invoice, buildEimsSeller(cfg), buildEimsContext(cfg, { - // Our own invoice number is the document number; EIMS only requires it to be unique. - documentNumber: invoice.invoiceNumber, + // 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, @@ -174,8 +181,9 @@ export class EimsInvoiceRegistrationService { * Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this * IRN is the one it holds, and that it belongs to this invoice. * - * The document-number check is against `DocumentDetails.DocumentNumber`, which registration set - * from our own `invoiceNumber` — the only field tying an IRN back to a row in this database. + * The document-number check is against `DocumentDetails.DocumentNumber`, which registration + * allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back + * to a row in this database. * * Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and * chains every later document to a stranger's reference, so both checks are refusals rather @@ -231,11 +239,34 @@ export class EimsInvoiceRegistrationService { }); } + // Cheap ownership check before touching the gateway: resolving an invoice that does not hold + // the reservation is a caller mistake, not something to spend a MoR round trip on. The + // authoritative re-check happens under lock in the transaction below. + const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query( + `SELECT in_flight_invoice_id FROM freight.eims_system_state + WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`, + [(await this.auth.getSessionContext()).systemNumber], + ); + if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) { + throw new ConflictException({ + code: "EIMS_RESOLVE_WRONG_INVOICE", + message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`, + }); + } + // Outside the transaction: no lock is held across the wire, and a refused verification must // leave the block exactly as it was. if (irn) { const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId); - await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber); + if (!invoice.eimsDocumentNumber) { + throw new BadRequestException({ + code: "EIMS_NO_DOCUMENT_NUMBER", + message: + `Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` + + "returned IRN cannot be tied back to it.", + }); + } + await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber); } // Same source of truth as registration: the state row is keyed by the token's system number. @@ -266,6 +297,7 @@ export class EimsInvoiceRegistrationService { ...(irn ? { previousIrn: irn } : {}), inFlightInvoiceId: null, inFlightCounter: null, + inFlightDocumentNumber: null, blockedReason: null, }); }); @@ -311,23 +343,27 @@ export class EimsInvoiceRegistrationService { if (invoice.eimsIrn) return null; const invoiceCounter = Number(state.nextInvoiceCounter); + const documentNumber = String(Number(state.nextDocumentNumber)); const previousIrn = state.previousIrn ?? ""; // Counter consumed here, not on success: once an attempt begins it can never be reused, // whatever happens next. A gap is harmless at MoR; a collision is not. await manager.update(EimsSystemState, state.id, { nextInvoiceCounter: invoiceCounter + 1, + nextDocumentNumber: Number(documentNumber) + 1, inFlightInvoiceId: invoiceId, inFlightCounter: invoiceCounter, + inFlightDocumentNumber: Number(documentNumber), }); await manager.update(Invoice, invoiceId, { eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: invoiceCounter, + eimsDocumentNumber: documentNumber, eimsSubmittedAt: new Date(), eimsLastError: null, }); - return { stateId: state.id, invoiceCounter, previousIrn }; + return { stateId: state.id, invoiceCounter, documentNumber, previousIrn }; }); } @@ -350,15 +386,26 @@ export class EimsInvoiceRegistrationService { previousIrn: irn, inFlightInvoiceId: null, inFlightCounter: null, + inFlightDocumentNumber: null, blockedReason: null, }); }); } /** - * TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and - * blocks the system number, because `PreviousIrn` is now unknown for every later document. - * The counter stays consumed either way. + * TX2b. A deterministic rejection releases the reservation **and returns the counter**; an + * ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown + * for every later document. + * + * The two numbers move differently, because MoR constrains them differently: + * + * - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A + * document MoR definitively refused was never counted there, so ours must not advance either. + * - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not + * unique". It is therefore spent by the attempt itself and never handed back, even for a + * refusal. + * + * An ambiguous result keeps both: MoR may have counted and stored the document. */ private async settleFailure( invoiceId: string, @@ -386,7 +433,15 @@ export class EimsInvoiceRegistrationService { EimsSystemState, reservation.stateId, deterministic - ? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null } + ? { + // Counter returns (MoR never counted a refused document); the document number does + // not (MoR requires it to be unique, so it is burned by the attempt). + nextInvoiceCounter: reservation.invoiceCounter, + inFlightInvoiceId: null, + inFlightCounter: null, + inFlightDocumentNumber: null, + blockedReason: null, + } : { blockedReason: `Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` + @@ -397,6 +452,41 @@ export class EimsInvoiceRegistrationService { }); this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`); + await this.alertStaff(invoiceId, status, lastError, deterministic); + } + + /** + * Tell the people who can act about a failed filing. + * + * An ambiguous result is the urgent one: it blocks *every* further invoice for this system + * number until a human resolves it, and nothing else in the system would surface that — the + * sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal + * priority. Never throws: an alert that fails must not mask the filing outcome. + */ + private async alertStaff( + invoiceId: string, + status: EimsInvoiceStatus, + error: EimsInvoiceError, + deterministic: boolean, + ): Promise { + try { + await this.inbox.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH, + title: deterministic + ? "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.` + : `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" }, + }); + } catch (err) { + this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(err as Error).message}`); + } } // ── internals ──────────────────────────────────────────────────────────────────────────────── @@ -488,6 +578,7 @@ export class EimsInvoiceRegistrationService { invoiceNumber: invoice.invoiceNumber, eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted, eimsIrn: invoice.eimsIrn ?? null, + eimsDocumentNumber: invoice.eimsDocumentNumber ?? null, eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter), eimsSubmittedAt: invoice.eimsSubmittedAt ?? null, eimsAckDate: invoice.eimsAckDate ?? null, diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts index ad6a3aa34..c4d9f3842 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -80,6 +80,8 @@ export interface EimsInvoiceStatusView { invoiceNumber: string; eimsStatus: EimsInvoiceStatus; eimsIrn: string | null; + /** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */ + eimsDocumentNumber: string | null; eimsInvoiceCounter: number | null; eimsSubmittedAt: Date | null; eimsAckDate: string | null; 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 79fe30f96..edd079b51 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,6 +33,8 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, + buyerRegionCodes: { "Addis Ababa": "13" }, + buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code cashierName: null, salesPersonName: null, ...over, 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 678b21b52..53d3d4090 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,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; +import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { EimsAuthService } from "./eims-auth.service"; import { EimsAutoSubmitService } from "./eims-auto-submit.service"; import { EimsClientService } from "./eims-client.service"; @@ -22,6 +23,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; imports: [ HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }), TypeOrmModule.forFeature([EimsSystemState, Invoice]), + NotificationInboxModule, ], controllers: [EimsInvoiceController], providers: [ diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts index ac6489c93..a21057042 100644 --- a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -18,6 +18,18 @@ export class EimsSystemState extends BaseEntity { @Column({ name: "next_invoice_counter", type: "bigint", default: 1 }) nextInvoiceCounter!: number; + /** + * `DocumentDetails.DocumentNumber` for the next registration. + * + * Separate from our own `invoiceNumber`, which MoR cannot accept: it validates the field against + * `^(0|[1-9][0-9]{0,8})$`, a plain integer. + */ + @Column({ name: "next_document_number", type: "bigint", default: 1 }) + nextDocumentNumber!: number; + + @Column({ name: "in_flight_document_number", type: "bigint", nullable: true }) + inFlightDocumentNumber?: number | null; + /** IRN of the last successful registration; null until the first one succeeds. */ @Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true }) previousIrn?: string | null; diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 255da0ea2..ee7b703d2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -60,14 +60,38 @@ export class RefundDto { } export class ClientActionDto { + // INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so + // must be assignable here, but freight never requests platform=inapp and therefore never + // receives one. Passenger owns that flow — see docs/telebirr-miniapp/. @ApiProperty({ - enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + enum: [ + "REDIRECT", + "LAUNCH_APP", + "INVOKE_BRIDGE", + "COLLECT_OTP", + "SHOW_BILL_REFERENCE", + ], }) - type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; + type!: + | "REDIRECT" + | "LAUNCH_APP" + | "INVOKE_BRIDGE" + | "COLLECT_OTP" + | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + bridge?: "TELEBIRR"; + + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + rawRequest?: string; + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) appId?: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index eefc560e8..d116a4b4a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -70,6 +70,16 @@ export class CreateCargoTypeDto { @IsBoolean() hasLashing?: boolean; + @ApiPropertyOptional({ + default: false, + description: + 'Allow staff to write bulk contract templates for this cargo type. ' + + 'Mutually exclusive with the parent group / children having it.', + }) + @IsOptional() + @IsBoolean() + hasContractTemplate?: boolean; + @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index e685f3a2d..b7717684a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -82,6 +82,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'has_lashing', type: 'boolean', default: false }) hasLashing!: boolean; + /** + * Whether staff may write bulk contract templates against this cargo type. + * Mutually exclusive between a parent group and its children: if the parent + * provides the template, no child may, and vice versa. + */ + @Column({ name: 'has_contract_template', type: 'boolean', default: false }) + hasContractTemplate!: boolean; + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 1f25b0823..9c11005e9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -135,6 +135,35 @@ export class CargoTypesService { return map; } + /** + * A cargo type and its parent group may not BOTH offer a contract template — + * the template would be ambiguous for bookings of the child. To enable the + * child, the parent must be turned off first (and vice versa). + */ + private async assertContractTemplateExclusive(input: { + id?: string; + parentGroupId?: string | null; + }): Promise { + if (input.parentGroupId) { + const parent = await this.repository.findById(input.parentGroupId); + if (parent?.hasContractTemplate) { + throw new BadRequestException( + `Parent group "${parent.cargoTypeName}" already has a contract template — turn it off there first`, + ); + } + } + if (input.id) { + const children = await this.repository.findAll({ + where: { parentGroupId: input.id, hasContractTemplate: true }, + }); + if (children.length) { + throw new BadRequestException( + `Child cargo type(s) ${children.map((c) => `"${c.cargoTypeName}"`).join(', ')} already have their own contract template — turn those off first`, + ); + } + } + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -144,6 +173,9 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + if (dto.hasContractTemplate) { + await this.assertContractTemplateExclusive({ parentGroupId: dto.parentGroupId }); + } const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', { explicitOrder: dto.displayOrder, @@ -160,6 +192,7 @@ export class CargoTypesService { code, cargoTypeName: dto.cargoTypeName, parentGroupId: dto.parentGroupId ?? null, + hasContractTemplate: dto.hasContractTemplate ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, @@ -183,6 +216,18 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + // Re-check the parent/child template exclusivity whenever the flag or the + // parent moves and the row ends up flagged. + const willHaveTemplate = dto.hasContractTemplate ?? existing.hasContractTemplate; + if ( + willHaveTemplate && + (dto.hasContractTemplate !== undefined || dto.parentGroupId !== undefined) + ) { + await this.assertContractTemplateExclusive({ + id, + parentGroupId: dto.parentGroupId ?? existing.parentGroupId, + }); + } const { wagonTypeIds, itemsPerWagonMap, 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 3c2be2e09..46da39aad 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1273,6 +1273,28 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:contract_templates:manage", "Edit contract templates & articles", ), + // Granular split of contract-template access. `view` opens the sidebar page; + // `read` is API-read-only for other pages that display template data. + perm( + "b4e00001-0001-4000-8000-000000000003", + "edr_freight_app:settings:contract_templates:create", + "Create bulk contract templates", + ), + perm( + "b4e00001-0001-4000-8000-000000000004", + "edr_freight_app:settings:contract_templates:update", + "Update contract templates & articles", + ), + perm( + "b4e00001-0001-4000-8000-000000000005", + "edr_freight_app:settings:contract_templates:delete", + "Delete bulk contract templates", + ), + perm( + "b4e00001-0001-4000-8000-000000000006", + "edr_freight_app:settings:contract_templates:read", + "Read contract template data (API only)", + ), ]; // N. Previously-ungated staff surfaces (support inbox, procurement, compliance, @@ -1816,6 +1838,10 @@ export const FREIGHT_PERMS = { contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", + create: "edr_freight_app:settings:contract_templates:create", + update: "edr_freight_app:settings:contract_templates:update", + delete: "edr_freight_app:settings:contract_templates:delete", + read: "edr_freight_app:settings:contract_templates:read", }, }, audit: { diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index 454817139..36bdb80de 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -12,3 +12,9 @@ VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 # observability stays off (the app works either way). Self-hosted instance. VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx VITE_POSTHOG_HOST=https://posthog.example.com + +# Maps JavaScript API key (fleet TrackingPage). Required — the hardcoded +# fallback in TrackingPage.tsx is expired (ExpiredKeyMapError), so without +# this set the tracking map renders blank. Get a key from the Google Cloud +# Console (Maps JavaScript API + Places API + Geocoding API enabled). +VITE_GOOGLE_MAPS_API_KEY= diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index bad3cbfe5..07299462c 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -835,7 +835,12 @@ const App = () => { + } @@ -843,7 +848,12 @@ const App = () => { + } diff --git a/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx new file mode 100644 index 000000000..ba507513c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx @@ -0,0 +1,158 @@ +import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { AlertTriangle, 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 { useToast } from "@/hooks/use-toast"; + +const STATUS_COLOR: Record = { + NOT_SUBMITTED: "gray", + SUBMITTING: "yellow", + REGISTERED: "edr-green", + FAILED: "red", + UNKNOWN: "orange", +}; + +const STATUS_LABEL: Record = { + NOT_SUBMITTED: "Not filed", + SUBMITTING: "Filing…", + REGISTERED: "Filed", + FAILED: "Rejected", + UNKNOWN: "Unacknowledged", +}; + +function Field({ label, value }: { label: string; value?: string | number | null }) { + return ( + + + {label} + + + {value === null || value === undefined || value === "" ? "—" : value} + + + ); +} + +/** + * MoR EIMS filing state for one invoice, with the manual actions. + * + * Filing normally happens on the API's cron sweep, not here — these controls exist for controlled + * testing and for the exceptional cases the sweep deliberately refuses: a rejected invoice that + * needs re-filing, and an unacknowledged one that has blocked all further filing. + */ +export function EimsFilingCard({ invoiceId }: { invoiceId: string }) { + const { user } = useAuth(); + const { toast } = useToast(); + const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister); + + const { data: eims, isLoading } = useQuery( + api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }), + ); + + const register = useMutation( + api.invoices.eimsRegister.mutationOptions({ + onSuccess: (result) => + toast({ + title: result.eimsIrn ? "Filed with MoR" : "Filing finished", + description: result.eimsIrn ? `IRN ${result.eimsIrn}` : `Status ${result.eimsStatus}`, + }), + }), + ); + + const verify = useMutation( + api.invoices.eimsVerify.mutationOptions({ + onSuccess: (result) => + toast({ + title: "MoR confirmed the filing", + description: `Document ${result.body?.DocumentDetails?.DocumentNumber ?? "—"}`, + }), + }), + ); + + if (isLoading || !eims) return null; + + const status = eims.eimsStatus; + const busy = register.isPending || verify.isPending; + + return ( + + + + + MoR e-invoicing + + + {STATUS_LABEL[status] ?? status} + + + + + + + + + + + {status === "UNKNOWN" && ( + } title="All filing is blocked"> + This invoice was sent but never acknowledged, so its IRN is unknown and no further + invoice can be filed. Confirm its status with MoR, then have a supervisor record the IRN + or discard the attempt. + + )} + + {eims.eimsLastError && ( + } + title={`MoR reported: ${eims.eimsLastError.kind}`} + > + {eims.eimsLastError.message} + + )} + + {canFile && ( + + {/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */} + {status !== "REGISTERED" && status !== "UNKNOWN" && ( + + )} + + {eims.eimsIrn && ( + + )} + + )} + + + ); +} + +export default EimsFilingCard; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 914a9cceb..5883da5b8 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -488,7 +488,11 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "Contract templates", href: "/dashboard/contract-templates", icon: , - permission: FREIGHT_PERMS.admin, + // `view` opens the page; `read` alone is API-only and shows no menu. + permission: [ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.admin, + ], }, { label: "Audit logs", diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx index 762f5bc84..e3fe9cb37 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx @@ -253,7 +253,7 @@ const RuleEngineCardGrid = ({ config={config} layout="compact" readOnly={readOnly} - onEdit={onEdit ?? (() => { })} + onEdit={onEdit} onDelete={onDelete ?? (() => { })} onViewChain={onViewChain} onSubmitRate={onSubmitRate} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx index 96440c001..f724d5b60 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx @@ -14,7 +14,7 @@ import type { RuleEngineRecord } from "@/types/rule-engine"; export interface RuleEngineRecordActionsProps { record: RuleEngineRecord; config: RuleEngineResourceConfig; - onEdit: (record: RuleEngineRecord) => void; + onEdit?: (record: RuleEngineRecord) => void; onDelete: (record: RuleEngineRecord) => void; onViewChain?: () => void; onSubmitRate?: (id: string) => void; @@ -93,17 +93,19 @@ const RuleEngineRecordActions = ({ ) : null}
- + {onEdit ? ( + + ) : null} + ) : undefined + } /> {isLoading - ? Array.from({ length: 10 }, (_, i) => ) + ? Array.from({ length: 6 }, (_, i) => ) : (templates ?? []).map((template) => ( setPreviewCode(template.code)} onEdit={() => navigate(`/dashboard/contract-templates/${template.code}`) } + onDelete={() => setDeleteTarget(template)} /> ))} @@ -100,22 +142,174 @@ export default function ContractTemplatesPage() { title={previewTemplate ? `${previewTemplate.name} — preview` : undefined} onClose={() => setPreviewCode(null)} /> + + setCreateOpen(false)} + onCreated={(code) => { + setCreateOpen(false); + navigate(`/dashboard/contract-templates/${code}`); + }} + /> + + {/* ── Delete confirm ─────────────────────────────────────── */} + setDeleteTarget(null)} + title="Delete contract template?" + centered + size="sm" + > + + + This will delete{" "} + + {deleteTarget?.name} + {" "} + and its articles. Contracts already generated keep their frozen + document; new contracts for this combination fall back to the + generic layout until a new template is created. + + + + + + + ); } +/** + * Staff pick the customs option first, then a bulk cargo type that has + * "has contract template" enabled. One template per combination — the API + * rejects duplicates, so an existing pairing must be edited instead. + */ +function CreateTemplateModal({ + opened, + onClose, + onCreated, +}: { + opened: boolean; + onClose: () => void; + onCreated: (code: string) => void; +}) { + const [withCustoms, setWithCustoms] = useState("true"); + const [cargoTypeId, setCargoTypeId] = useState(null); + const create = useCreateContractTemplate(); + + const { data: cargoTypes, isLoading } = useQuery({ + queryKey: ["cargo-types", "contract-template-options"], + queryFn: () => cargoTypesService.getCargoTypes(), + enabled: opened, + }); + + const options = useMemo( + () => + ((cargoTypes ?? []) as CargoTypeOption[]) + .filter((cargoType) => cargoType.hasContractTemplate) + .map((cargoType) => ({ + value: cargoType.id, + label: cargoType.cargoTypeName ?? "Untitled", + })), + [cargoTypes], + ); + + const close = () => { + setCargoTypeId(null); + onClose(); + }; + + return ( + + +
+ + Customs clearing + + +
+ + + {passengerList.length > 0 && ( (null); const [billCopied, setBillCopied] = useState(false); + // Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're + // polling our own status endpoint for the webhook-backed outcome. + const [verifyingPayment, setVerifyingPayment] = useState(false); + + // Resolved once on mount — SSR has no `window`, so this must not be read during render + // of the first (server) pass. + const [inMiniApp, setInMiniApp] = useState(false); + useEffect(() => { + setInMiniApp(isTelebirrMiniApp()); + }, []); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; // Use the same display currency as the review page — stored on the schedule at search time. @@ -72,8 +88,26 @@ export default function PaymentPage() { }, }); + // Inside the telebirr SuperApp only telebirr can complete: every other method is a + // redirect/HPP flow, and the mini-app WebView cannot follow the scheme handoffs those + // gateways use. Offering them would strand the payer on a dead page. + // Memoised: this feeds an effect's dep array, and a fresh array identity every render + // would re-run that effect on every render. + const availableMethods = useMemo( + () => paymentMethods.filter((m) => m.enabled && (!inMiniApp || m.type === 'TELEBIRR')), + [paymentMethods, inMiniApp], + ); + const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null; + // A method chosen before the container was known (or carried over in state) may no longer + // be offerable — drop it rather than letting Pay fire against a hidden method. + useEffect(() => { + if (selectedMethod && !availableMethods.some((m) => m.type === selectedMethod)) { + setSelectedMethod(null); + } + }, [selectedMethod, availableMethods]); + // Derive charge currency directly from the selected method — no separate state that can lag. const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase(); @@ -128,6 +162,70 @@ export default function PaymentPage() { } }, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]); + /** + * Poll our own status endpoint until the payment reaches a terminal state. + * + * Used by the telebirr mini-app flow, where nothing navigates and therefore no return page + * ever runs. The bridge callback only tells us the sheet closed; the authoritative outcome + * is the webhook-backed status the API reports here. + */ + const pollPaymentStatus = useCallback( + async (attemptsLeft: number): Promise => { + if (!bookingId) return; + try { + const res: any = await apiClient.get(`/payments/status/${bookingId}`); + if (res?.status === 'SUCCEEDED') { + setVerifyingPayment(false); + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); + return; + } + if (res?.status === 'FAILED' || res?.status === 'CANCELLED') { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError(res?.failureMessage || "Payment was not completed. Please try again."); + return; + } + } catch { + // Transient read failure — keep polling; the attempt budget bounds it. + } + + if (attemptsLeft <= 0) { + // Don't call it failed: telebirr may have taken the money and the webhook is simply + // still in flight. Stop spinning, tell the truth, and let the payer re-check. + setVerifyingPayment(false); + setIsProcessing(false); + setPaymentError( + "We haven't received confirmation yet. If you completed the payment, your booking " + + "will be confirmed shortly — check My Bookings in a moment before paying again.", + ); + return; + } + setTimeout(() => void pollPaymentStatus(attemptsLeft - 1), 1500); + }, + [bookingId, router, updateStatus], + ); + + /** + * Telebirr mini app reports the sheet outcome on a global callback rather than a redirect. + * Registered on mount — the SuperApp can call back the moment the sheet closes, so it must + * already be installed before the bridge is invoked. + */ + useEffect(() => { + return onTelebirrPayResult((succeeded) => { + if (!succeeded) { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError("Payment was cancelled or declined. Please try again."); + return; + } + setVerifyingPayment(true); + void pollPaymentStatus(15); + }); + }, [pollPaymentStatus, updateStatus]); + const paymentMutation = useMutation({ mutationFn: async (data: any) => { return await apiClient.post("/payments/initiate", { @@ -135,7 +233,7 @@ export default function PaymentPage() { method: data.method, paymentMethodId: data.paymentMethodId, payerAccount: data.payerAccount, - platform: 'web', + platform: isTelebirrMiniApp() ? 'inapp' : 'web', }); }, onSuccess: async (data: any) => { @@ -163,6 +261,23 @@ export default function PaymentPage() { return; } + // Telebirr mini app: hand the signed rawRequest to the SuperApp bridge. Nothing + // navigates — telebirr draws its payment sheet over the WebView and reports back on + // the global callback registered above, which starts the status polling. + if (data?.clientAction?.type === 'INVOKE_BRIDGE') { + setPaymentIntent(data.intentId); + updateStatus("REQUIRES_ACTION"); + if (!startTelebirrPay(data.clientAction.rawRequest)) { + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError( + "Couldn't open the telebirr payment sheet. Please reopen this page from the " + + "telebirr app and try again.", + ); + } + return; + } + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') { setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); @@ -505,7 +620,15 @@ export default function PaymentPage() { {isProcessing && (
- {paymentMutation.isSuccess ? ( + {verifyingPayment ? ( + <> + +

Confirming payment

+

+ Checking with telebirr — this only takes a moment. +

+ + ) : paymentMutation.isSuccess ? ( <>

Loading...

@@ -688,13 +811,13 @@ export default function PaymentPage() {

Failed to load payment methods. Please refresh.

- ) : paymentMethods.length === 0 ? ( + ) : availableMethods.length === 0 ? (

No payment methods available at the moment.

) : (
- {paymentMethods.filter(m => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( diff --git a/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts new file mode 100644 index 000000000..fd2a9ccea --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts @@ -0,0 +1,106 @@ +/** + * Telebirr SuperApp mini-app bridge. + * + * When the portal runs inside the telebirr SuperApp, the ordinary web checkout is unusable: + * the H5 paygate page hands off to the native wallet with a custom scheme + * (`kcbconsumer://h5checkout?...`) that the SuperApp's WebView cannot resolve, so the payer + * only ever sees `net::ERR_UNKNOWN_URL_SCHEME`. + * + * The in-app flow never navigates. The API returns a signed `rawRequest` string + * (clientAction.type === "INVOKE_BRIDGE") which is handed to the host's JS bridge; telebirr + * renders its own payment sheet over the WebView and reports the outcome on a global callback. + * + * See docs/telebirr-miniapp/inapp-payment-plan.md. + */ + +/** Name of the global the SuperApp calls back into. Must be a property of `window`. */ +export const TELEBIRR_PAY_CALLBACK = "handleEdrPaymentCallback"; + +type ConsumerApp = { evaluate: (payload: string) => void }; + +declare global { + interface Window { + /** Injected by the telebirr SuperApp WebView. Absent everywhere else. */ + consumerapp?: ConsumerApp; + [TELEBIRR_PAY_CALLBACK]?: (response: unknown) => void; + } +} + +/** + * True only when the telebirr host bridge is actually present. + * + * Deliberately does NOT sniff the user agent. A UA match without `window.consumerapp` would + * make us request `platform: "inapp"` and get back a bare rawRequest we have no way to use — + * there is no navigating our way out of that, because by then the server has already committed + * to the bridge payload. Gating on the bridge object keeps the decision and the capability in + * sync: if we can't call it, we don't ask for it. + */ +export function isTelebirrMiniApp(): boolean { + return typeof window !== "undefined" && typeof window.consumerapp?.evaluate === "function"; +} + +/** + * Hand a signed rawRequest to the SuperApp to open its payment sheet. + * + * Register the callback (see `onTelebirrPayResult`) BEFORE calling this — the host may invoke + * it as soon as the sheet closes. Returns false when the bridge is missing or throws, so the + * caller can surface an error instead of leaving the payer on a dead spinner. + */ +export function startTelebirrPay(rawRequest: string): boolean { + if (!isTelebirrMiniApp()) return false; + try { + window.consumerapp!.evaluate( + JSON.stringify({ + functionName: "js_fun_start_pay", + params: { + rawRequest, + functionCallBackName: TELEBIRR_PAY_CALLBACK, + }, + }), + ); + return true; + } catch (err) { + console.error("[telebirr] bridge evaluate failed:", err); + return false; + } +} + +/** + * Install the global result callback; returns a disposer for effect cleanup. + * + * The result is a TRIGGER TO VERIFY, never proof of payment — the payer can close the sheet, + * the host can report success before settlement lands, and the payload shape is not a contract. + * Confirmation always comes from polling our own payment status (webhook-backed). + */ +export function onTelebirrPayResult(handler: (succeeded: boolean) => void): () => void { + if (typeof window === "undefined") return () => {}; + window[TELEBIRR_PAY_CALLBACK] = (response: unknown) => { + handler(isSuccessResponse(response)); + }; + return () => { + delete window[TELEBIRR_PAY_CALLBACK]; + }; +} + +/** + * Telebirr reports `code: 0` (number or string) for success. The payload arrives as either a + * JSON string or an object depending on host version, and an unparseable payload is treated as + * success on purpose: polling is what decides the outcome, and a false "failed" would strand a + * payer who actually paid. + */ +function isSuccessResponse(response: unknown): boolean { + let parsed: unknown = response; + if (typeof response === "string") { + try { + parsed = JSON.parse(response); + } catch { + return true; + } + } + if (parsed && typeof parsed === "object" && "code" in parsed) { + const code = (parsed as { code: unknown }).code; + if (code === undefined || code === null) return true; + return code === 0 || code === "0"; + } + return true; +} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index 6d602b28a..fa7587b2e 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -62,9 +62,14 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest { @IsEnum(ProviderMethod) provider!: ProviderMethod; - @ApiPropertyOptional({ enum: ["web", "mobile"] }) + @ApiPropertyOptional({ + enum: ["web", "mobile", "inapp"], + description: + "Payer surface. `inapp` = running inside a SuperApp mini-app WebView (Telebirr), " + + "which cannot follow redirect/HPP flows and gets a bridge payload instead.", + }) @IsOptional() - @IsIn(["web", "mobile"]) + @IsIn(["web", "mobile", "inapp"]) platform?: PaymentPlatform; @ApiPropertyOptional({ diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 51e90381b..3af351e32 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider { return this.config.get("dmoney.returnUrl") ?? ""; } private get timeoutExpress(): string { - return this.config.get("dmoney.timeoutExpress") ?? "120m"; + return this.config.get("dmoney.timeoutExpress") ?? "5m"; } private get language(): string { return this.config.get("dmoney.language") ?? "en"; diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index d732a51e3..32bf859b1 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { HttpService } from "@nestjs/axios"; import { + ClientAction, + PaymentPlatform, PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, @@ -67,15 +69,7 @@ export class TelebirrProvider implements PaymentProvider { requestBody.biz_content.timeout_express, ); const platform = input.platform ?? "web"; - const clientAction = - platform === "mobile" - ? { - type: "LAUNCH_APP" as const, - appId: this.merchantAppId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) }; + const clientAction = this.buildClientAction(platform, prepayId, response); return { providerOrderId: prepayId, @@ -199,6 +193,11 @@ export class TelebirrProvider implements PaymentProvider { input: ProviderInitiationInput, ): CreateOrderRequest { const totalAmount = String(input.amountMinor); + // In-app pays inside the SuperApp overlay and never navigates, so there is no browser + // to send back — telebirr's own in-app integration omits redirect_url entirely. Keep it + // absent rather than undefined: a signed-but-unsent field is what produced the earlier + // "verify sign failed" (see docs/payment-service + telebirr.crypto skip-undefined). + const wantsRedirect = input.platform !== "inapp" && !!input.redirectUrl; const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), @@ -214,7 +213,7 @@ export class TelebirrProvider implements PaymentProvider { total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - ...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}), + ...(wantsRedirect ? { redirect_url: input.redirectUrl! } : {}), }, }; const sign = signRequestObject( @@ -245,6 +244,68 @@ export class TelebirrProvider implements PaymentProvider { return { ...req, sign, sign_type: "SHA256WithRSA" }; } + /** + * Telebirr exposes the same pre-order three ways; only the launch payload differs. + * + * - `mobile` — native app hands off to the wallet app with a receiveCode. + * - `inapp` — the portal is running inside the telebirr SuperApp mini-app WebView. The + * H5 checkout page is unusable there: it deep-links to `kcbconsumer://…`, + * which the WebView cannot resolve (`net::ERR_UNKNOWN_URL_SCHEME`). The + * signed rawRequest goes to the host JS bridge instead — no navigation. + * - `web` — ordinary browser; redirect to the H5 checkout page. + */ + private buildClientAction( + platform: PaymentPlatform, + prepayId: string, + response: CreateOrderResponse, + ): ClientAction { + switch (platform) { + case "mobile": + return { + type: "LAUNCH_APP", + appId: this.merchantAppId, + receiveCode: response.biz_content?.receiveCode, + shortCode: this.merchantCode, + }; + case "inapp": + return { + type: "INVOKE_BRIDGE", + bridge: "TELEBIRR", + rawRequest: this.buildInAppRawRequest(prepayId), + }; + default: + return { type: "REDIRECT", url: this.buildCheckoutUrl(prepayId) }; + } + } + + /** + * Signed request handed verbatim to the SuperApp bridge (`js_fun_start_pay`). + * + * Emits `appid, merch_code, nonce_str, prepay_id, timestamp, sign_type, sign` in that + * order — no `webBaseUrl` prefix and no `version`/`trade_type` tail, because the bridge + * takes the bare query string rather than a URL. + * + * `sign_type` sits in the map purely so it lands in the output in the right position; + * `buildCanonicalString` excludes it (as does telebirr's own reference implementation), + * so the signature covers the same five fields as the web checkout URL. + * + * Kept separate from `buildCheckoutUrl` rather than sharing a builder: the two payloads + * are consumed by different validators, and the web flow is live. + */ + private buildInAppRawRequest(prepayId: string): string { + const map: Record = { + appid: this.merchantAppId, + merch_code: this.merchantCode, + nonce_str: createNonceStr(), + prepay_id: prepayId, + timestamp: createTimestamp(), + sign_type: "SHA256WithRSA", + }; + const sign = signRequestObject(map, this.privateKey); + const fields = Object.entries(map).map(([k, v]) => `${k}=${v}`); + return [...fields, `sign=${sign}`].join("&"); + } + private buildCheckoutUrl(prepayId: string): string { const map: Record = { appid: this.merchantAppId, diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 04849f54d..05be21682 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -32,7 +32,8 @@ export enum ProviderMethod { CBE_BILL = "CBE_BILL", } -export type PaymentPlatform = "web" | "mobile"; + +export type PaymentPlatform = "web" | "mobile" | "inapp"; export type ClientAction = | { type: "REDIRECT"; url: string } @@ -42,6 +43,16 @@ export type ClientAction = receiveCode?: string; shortCode: string; } + | { + type: "INVOKE_BRIDGE"; + /** Which SuperApp host bridge the payload targets. */ + bridge: "TELEBIRR"; + /** + * Signed query string handed verbatim to the host bridge (`js_fun_start_pay`). + * NOT a URL — it has no scheme or host and must never be navigated to. + */ + rawRequest: string; + } | { type: "COLLECT_OTP"; providerOrderId: string;