From 2a3a2316657e14a58b88aed7cf6e0dbb10ff4647 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 12 Aug 2026 08:25:39 +0000 Subject: [PATCH 01/25] fix: auto select ethiopian when chosing the coop option --- .../src/components/onboarding/OnboardingWizardDialog.tsx | 3 ++- .../portal/src/pages/settings/NationalitySelect.tsx | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 998f733da..d5a87a4ee 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -169,7 +169,8 @@ export default function OnboardingWizardDialog({ setCooperative(checked); if (checked) { setRoles((prev) => prev.filter((r) => r !== "freight_forwarder")); - setNationality((prev) => (prev === "foreign" ? "ethiopian" : prev)); + // Ethiopian is then the only answer left, so it is made rather than asked. + setNationality("ethiopian"); } }, []); const [documentFiles, setDocumentFiles] = useState< diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx index 99569e748..3eb7fd87e 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -29,7 +29,11 @@ export default function NationalitySelect({ } selected={value === "ethiopian"} onClick={() => onChange("ethiopian")} From abeb296157e8f6dfe858bd187ae0a2ba48311c94 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 12 Aug 2026 08:39:54 +0000 Subject: [PATCH 02/25] fix: file upload seeder --- .../src/seed/file-upload-settings.seeder.ts | 79 ++++++++++++------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 4f314453a..dd8f9bd20 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; +import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants"; @@ -165,10 +166,13 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ * actually produce is a backoffice decision, edited in the file-settings editor. */ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [ + // First on purpose: only the first field of a new set is seeded, and this is + // the paper that distinguishes a co-operative from every other company. { - fileKey: "tin_certificate", - fileLabel: "TIN Certificate", - helpText: "Verified against the TIN registry during registration.", + fileKey: "cooperative_registration_certificate", + fileLabel: "Co-operative Union / Farm Registration Certificate", + helpText: + "Certificate issued by the co-operative promotion agency that registered the union or farm.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -177,10 +181,9 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [ displayOrder: 1, }, { - fileKey: "cooperative_registration_certificate", - fileLabel: "Co-operative Union / Farm Registration Certificate", - helpText: - "Certificate issued by the co-operative promotion agency that registered the union or farm.", + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + helpText: "Verified against the TIN registry during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -662,16 +665,15 @@ export class FileUploadSettingsSeeder { async run() { const settingRepository = this.dataSource.getRepository(FileUploadSetting); - // Seed only into an empty table: any existing rows (including - // soft-deleted ones, which would still conflict on the unique `code`) - // mean the data is admin-managed, so leave it untouched. - const existing = await settingRepository.count({ withDeleted: true }); - if (existing > 0) { - this.logger.log( - `file_upload_settings already has ${existing} rows — skipping seed`, - ); - return; - } + // Seed per CODE, not "only into an empty table". An existing row is + // admin-managed and never touched — including a soft-deleted one, which + // means the set was removed on purpose (and would still conflict on the + // unique `code`). What the table-wide check got wrong is the other half: a + // set added to this file after the first boot could never reach a database + // that already held the others, so it existed in code and nowhere else. + const existingCodes = new Set( + (await settingRepository.find({ withDeleted: true })).map((s) => s.code), + ); const allSettings: Array< OnboardingDocumentSetting & { description: string } @@ -701,20 +703,41 @@ export class FileUploadSettingsSeeder { })), ]; - // Insert setting rows only — no FileUploadField rows. Fields start empty - // and are configured from the backoffice file-settings editor; the field - // definitions above are kept as reference defaults. - await settingRepository.insert( - allSettings.map((documentSetting) => ({ - code: documentSetting.code, - label: documentSetting.label, - description: documentSetting.description, - entity: documentSetting.entity, - })), + const missing = allSettings.filter((s) => !existingCodes.has(s.code)); + if (missing.length === 0) { + this.logger.log("file upload settings up to date — nothing to seed"); + return; + } + + const inserted = await settingRepository.save( + missing.map((documentSetting) => + settingRepository.create({ + code: documentSetting.code, + label: documentSetting.label, + description: documentSetting.description, + entity: documentSetting.entity, + }), + ), ); + // A brand-new set gets exactly ONE field: its first reference default. The + // rest of the list above stays documentation — what a set actually asks for + // is a backoffice decision, edited in the file-settings editor. Seeding one + // means a set is never born empty (an empty set silently requires nothing), + // while leaving the admin a single row to extend rather than a list to prune. + const fieldRepository = this.dataSource.getRepository(FileUploadField); + const firstFields = inserted.flatMap((setting) => { + const reference = missing.find((s) => s.code === setting.code)?.fields[0]; + return reference + ? [fieldRepository.create({ ...reference, settingId: setting.id })] + : []; + }); + if (firstFields.length > 0) await fieldRepository.save(firstFields); + this.logger.log( - `Seeded ${allSettings.length} file upload settings with empty fields`, + `Seeded ${missing.length} file upload settings (${firstFields.length} with a default field): ${missing + .map((s) => s.code) + .join(", ")}`, ); } } From 325cbbc8828345b8585da2a9680e9ceef7b6a71f Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 08:40:02 +0000 Subject: [PATCH 03/25] fix issue --- pnpm-lock.yaml | 162 ++++++++++++++++++++++++------------------------- 1 file changed, 78 insertions(+), 84 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20f1adbc4..78ec96cd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,16 +237,16 @@ importers: version: 18.0.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@hookform/resolvers': specifier: ^5.4.0 - version: 5.4.0(react-hook-form@7.77.0(react@19.2.6)) + version: 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76) '@mantine/core': specifier: ^9.3.0 - version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/dates': specifier: ^9.3.0 - version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': specifier: ^9.3.0 - version: 9.3.0(react@19.2.6) + version: 9.3.2(react@19.2.6) '@mdxeditor/editor': specifier: ^4.2.0 version: 4.2.0(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32) @@ -354,7 +354,7 @@ importers: version: 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@8.6.0) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(e4483b467c1e4b1b4b359f6670e164a6) '@types/three': specifier: ^0.185.3 version: 0.185.3 @@ -586,13 +586,13 @@ importers: version: 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3) '@mantine/core': specifier: ^9.3.0 - version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/dates': specifier: ^9.3.0 - version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': specifier: ^9.3.0 - version: 9.3.0(react@19.2.6) + version: 9.3.2(react@19.2.6) '@posthog/react': specifier: ^1.10.3 version: 1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6) @@ -601,7 +601,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(ea394f4c73a62db876565d0b255299aa) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1436,7 +1436,7 @@ importers: version: link:../types '@mantine/core': specifier: ^9.3.0 - version: 9.3.0(@mantine/hooks@9.3.0(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 9.3.2(@mantine/hooks@9.3.2(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2276,11 +2276,6 @@ packages: peerDependencies: react-hook-form: ^7.0.0 - '@hookform/resolvers@5.4.0': - resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} - peerDependencies: - react-hook-form: ^7.55.0 - '@hookform/resolvers@5.6.0': resolution: {integrity: sha512-qtgE4NUK/WQFPq8aDe+GOhr0/UiUSKT0m9ta9SMnDS5ZE63yC850ShAGz1lxtwO+dBjhUKvUxmHwkp4eN7/kBQ==} peerDependencies: @@ -2918,13 +2913,6 @@ packages: react: ^18.x || ^19.x react-dom: ^18.x || ^19.x - '@mantine/core@9.3.0': - resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==} - peerDependencies: - '@mantine/hooks': 9.3.0 - react: ^19.2.0 - react-dom: ^19.2.0 - '@mantine/core@9.3.2': resolution: {integrity: sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==} peerDependencies: @@ -2955,11 +2943,6 @@ packages: peerDependencies: react: ^18.x || ^19.x - '@mantine/hooks@9.3.0': - resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==} - peerDependencies: - react: ^19.2.0 - '@mantine/hooks@9.3.2': resolution: {integrity: sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==} peerDependencies: @@ -5224,9 +5207,6 @@ packages: '@types/jquery@3.5.34': resolution: {integrity: sha512-3m3939S3erqmTLJANS/uy0B6V7BorKx7RorcGZVjZ62dF5PAGbKEDZK1CuLtKombJkFA2T1jl8LAIIs7IV6gBQ==} - '@types/jquery@4.0.1': - resolution: {integrity: sha512-9a59A/tycXgYuPABcp6/3spSShn0NT2UOM4EfHvMumjYi4lJWTsK5SZWjhx3yRm9IHGCeWXdV2YfNsrWrft/CA==} - '@types/js-cookie@3.0.6': resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} @@ -9068,10 +9048,6 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} - hasBin: true - js-yaml@4.3.0: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true @@ -13960,7 +13936,7 @@ snapshots: globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -14093,10 +14069,19 @@ snapshots: dependencies: react-hook-form: 7.77.0(react@18.3.1) - '@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.6))': + '@hookform/resolvers@5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76)': dependencies: '@standard-schema/utils': 0.3.0 react-hook-form: 7.77.0(react@19.2.6) + optionalDependencies: + '@sinclair/typebox': 0.27.10 + '@standard-schema/spec': 1.1.0 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + class-transformer: 0.5.1 + class-validator: 0.14.4 + effect: 3.21.0 + zod: 3.25.76 '@hookform/resolvers@5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3)': dependencies: @@ -14879,10 +14864,10 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react': 0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 9.3.0(react@18.3.1) + '@mantine/hooks': 9.3.2(react@18.3.1) clsx: 2.1.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -14892,19 +14877,6 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 9.3.0(react@19.2.6) - clsx: 2.1.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-number-format: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@19.2.6) - type-fest: 5.7.0 - transitivePeerDependencies: - - '@types/react' - '@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -14927,15 +14899,6 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@mantine/dates@9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 9.3.0(react@19.2.6) - clsx: 2.1.1 - dayjs: 1.11.21 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - '@mantine/dates@9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@mantine/core': 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -14949,14 +14912,10 @@ snapshots: dependencies: react: 19.2.6 - '@mantine/hooks@9.3.0(react@18.3.1)': + '@mantine/hooks@9.3.2(react@18.3.1)': dependencies: react: 18.3.1 - '@mantine/hooks@9.3.0(react@19.2.6)': - dependencies: - react: 19.2.6 - '@mantine/hooks@9.3.2(react@19.2.6)': dependencies: react: 19.2.6 @@ -17951,11 +17910,11 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)': + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(e4483b467c1e4b1b4b359f6670e164a6)': dependencies: '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) + '@hookform/resolvers': 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76) '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -18058,11 +18017,29 @@ snapshots: - '@mui/icons-material' - '@mui/material' - '@mui/x-date-pickers' + - '@sinclair/typebox' + - '@standard-schema/spec' - '@types/prop-types' - '@types/react' - '@types/react-dom' + - '@typeschema/main' + - '@vinejs/vine' + - ajv + - ajv-errors + - ajv-formats + - arktype + - ata-validator - bufferutil + - class-transformer + - class-validator + - computed-types - debug + - effect + - fluentvalidation-ts + - fp-ts + - io-ts + - joi + - nope-validator - pdfjs-dist - prop-types - react-is @@ -18070,16 +18047,21 @@ snapshots: - redux - rolldown - rollup + - superstruct - supports-color + - typanion - typescript - utf-8-validate + - valibot + - vest - vite + - yup - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(ea394f4c73a62db876565d0b255299aa)': dependencies: '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) + '@hookform/resolvers': 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76) '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -18182,11 +18164,29 @@ snapshots: - '@mui/icons-material' - '@mui/material' - '@mui/x-date-pickers' + - '@sinclair/typebox' + - '@standard-schema/spec' - '@types/prop-types' - '@types/react' - '@types/react-dom' + - '@typeschema/main' + - '@vinejs/vine' + - ajv + - ajv-errors + - ajv-formats + - arktype + - ata-validator - bufferutil + - class-transformer + - class-validator + - computed-types - debug + - effect + - fluentvalidation-ts + - fp-ts + - io-ts + - joi + - nope-validator - pdfjs-dist - prop-types - react-is @@ -18194,10 +18194,15 @@ snapshots: - redux - rolldown - rollup + - superstruct - supports-color + - typanion - typescript - utf-8-validate + - valibot + - vest - vite + - yup '@ts-morph/common@0.27.0': dependencies: @@ -18397,8 +18402,6 @@ snapshots: dependencies: '@types/sizzle': 2.3.10 - '@types/jquery@4.0.1': {} - '@types/js-cookie@3.0.6': {} '@types/json-schema@7.0.15': {} @@ -18537,7 +18540,7 @@ snapshots: '@types/tinymce@4.6.9': dependencies: - '@types/jquery': 4.0.1 + '@types/jquery': 3.5.34 '@types/tmp@0.2.6': {} @@ -18982,11 +18985,6 @@ snapshots: amqplib: 0.10.9 promise-breaker: 6.0.0 - amqp-connection-manager@5.0.0(amqplib@0.10.9): - dependencies: - amqplib: 0.10.9 - promise-breaker: 6.0.0 - amqp-connection-manager@5.0.0(amqplib@2.0.1): dependencies: amqplib: 2.0.1 @@ -20148,7 +20146,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -20158,7 +20156,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -21054,7 +21052,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.2.0 + js-yaml: 4.3.0 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -22887,10 +22885,6 @@ snapshots: dependencies: argparse: 2.0.1 - js-yaml@4.2.0: - dependencies: - argparse: 2.0.1 - js-yaml@4.3.0: dependencies: argparse: 2.0.1 From 879f0b890da66ae1a828b0a7cabf4dd0f4309c00 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 12 Aug 2026 08:42:16 +0000 Subject: [PATCH 04/25] fix: file size limit --- .../portal/src/components/onboarding/RoleLicenseStep.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index a485d40c1..8270bce74 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -50,7 +50,7 @@ function buildLicenseSetting( isMultiple: true, maxFiles: 10, allowedExtensions: ["pdf", "png", "jpg", "jpeg"], - maxSizeMb: 10, + maxSizeMb: 25, order: 1, }, ], From 08bd110e5b849072d494c7aa1f4978f389ad32a9 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 08:56:47 +0000 Subject: [PATCH 05/25] changes --- pnpm-lock.yaml | 307 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 255 insertions(+), 52 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f90cc87f2..a56272063 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,7 +354,7 @@ importers: version: 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@8.6.0) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(e4483b467c1e4b1b4b359f6670e164a6) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) '@types/three': specifier: ^0.185.3 version: 0.185.3 @@ -601,7 +601,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -13032,11 +13032,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -13071,7 +13071,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -13080,7 +13080,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13095,9 +13102,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -13112,13 +13119,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13271,6 +13278,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -13755,7 +13774,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -13921,7 +13940,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -14090,7 +14109,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -15663,7 +15682,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -17737,7 +17756,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -18047,6 +18066,153 @@ snapshots: - vite - yup + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(ea394f4c73a62db876565d0b255299aa)': + dependencies: + '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) + '@hookform/resolvers': 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@3.25.76) + '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) + '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) + '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) + '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf/renderer': 4.5.1(react@19.2.6) + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + '@tabler/icons-react': 3.44.0(react@19.2.6) + '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) + '@tanstack/react-query': 5.101.0(react@19.2.6) + '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) + '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) + '@types/dompurify': 3.2.0 + '@types/node': 24.13.1 + '@types/tinymce': 4.6.9 + axios: 1.17.0 + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + date-fns: 3.6.0 + dayjs: 1.11.21 + dompurify: 3.4.8 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-calendar-new: 1.1.0 + file-type: 18.7.0 + framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + html2canvas: 1.4.1 + i18next: 25.10.10(typescript@5.9.3) + i18next-browser-languagedetector: 8.2.1 + jquery: 3.7.1 + js-cookie: 3.0.8 + jspdf: 3.0.4 + lodash: 4.18.1 + lucide-react: 0.513.0(react@19.2.6) + mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) + next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + path: 0.12.7 + pdf-lib: 1.17.1 + qs: 6.15.2 + react: 19.2.6 + react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) + react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-dropzone: 14.4.1(react@19.2.6) + react-hook-form: 7.77.0(react@19.2.6) + react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-icons: 5.6.0(react@19.2.6) + react-image-crop: 11.0.10(react@19.2.6) + react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) + react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) + react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) + rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) + socket.io-client: 4.8.3 + sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tailwind-merge: 3.6.0 + tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) + tailwindcss: 4.3.0 + tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) + tinymce: 7.9.3 + url: 0.11.4 + vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + xlsx: 0.18.5 + zod: 3.25.76 + transitivePeerDependencies: + - '@babel/core' + - '@emotion/is-prop-valid' + - '@mui/icons-material' + - '@mui/material' + - '@mui/x-date-pickers' + - '@sinclair/typebox' + - '@standard-schema/spec' + - '@types/prop-types' + - '@types/react' + - '@types/react-dom' + - '@typeschema/main' + - '@vinejs/vine' + - ajv + - ajv-errors + - ajv-formats + - arktype + - ata-validator + - bufferutil + - class-transformer + - class-validator + - computed-types + - debug + - effect + - fluentvalidation-ts + - fp-ts + - io-ts + - joi + - nope-validator + - pdfjs-dist + - prop-types + - react-is + - react-native + - redux + - rolldown + - rollup + - superstruct + - supports-color + - typanion + - typescript + - utf-8-validate + - valibot + - vest + - vite + - yup + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -18442,7 +18608,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -18452,7 +18618,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18471,7 +18637,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -18486,7 +18652,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -18775,7 +18941,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -19284,6 +19450,16 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): + dependencies: + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + picomatch: 4.0.4 + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - supports-color + babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -19439,7 +19615,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -20488,7 +20664,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -20508,7 +20684,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -20739,7 +20915,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -20867,7 +21043,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -21104,7 +21280,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -21157,7 +21333,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -21312,7 +21488,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -21560,7 +21736,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -21869,7 +22045,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -21882,14 +22058,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -22329,7 +22505,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -22985,7 +23161,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -23672,7 +23848,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -24198,7 +24374,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -24548,7 +24724,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -24577,7 +24753,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -24830,6 +25006,15 @@ snapshots: - '@babel/core' - react-is + react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - react-is + react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -25440,7 +25625,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -25562,7 +25747,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -25778,7 +25963,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -25788,7 +25973,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -25799,7 +25984,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -25808,7 +25993,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -25820,7 +26005,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -26115,6 +26300,24 @@ snapshots: transitivePeerDependencies: - '@babel/core' + styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/stylis': 0.8.5 + '@emotion/unitless': 0.7.5 + babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) + css-to-react-native: 3.2.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 19.2.7 + shallowequal: 1.1.0 + supports-color: 5.5.0 + transitivePeerDependencies: + - '@babel/core' + styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -26140,7 +26343,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -26653,7 +26856,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -26677,7 +26880,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -27038,7 +27241,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27056,7 +27259,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27103,7 +27306,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -27139,7 +27342,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 From 357aae7d82dc060e4421236907cd68795c2a4f30 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 09:08:08 +0000 Subject: [PATCH 06/25] fix issue --- pnpm-lock.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a56272063..1538a044a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,7 +354,7 @@ importers: version: 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@8.6.0) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(e4483b467c1e4b1b4b359f6670e164a6) '@types/three': specifier: ^0.185.3 version: 0.185.3 @@ -601,7 +601,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(ea394f4c73a62db876565d0b255299aa) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -20888,7 +20888,7 @@ snapshots: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -20912,7 +20912,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@8.1.1) @@ -20927,14 +20927,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -20949,7 +20949,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 From 4efd65c1057b37c7f6c9f2498c1b23a177359381 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 09:20:28 +0000 Subject: [PATCH 07/25] feat: key bulk templates by trade direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk contract templates are now unique per (cargo type, trade direction, customs option) instead of (cargo type, customs option). Intercity is domestic and crosses no border, so it carries no customs variant: with_customs stays null there, enforced by a check constraint. Contract resolution already passed the trade direction through but the bulk lookup dropped it, so one template served all three directions. Container templates are unchanged — cargo_type_id is null on those rows, which keeps them out of both the new index and the constraint. Co-Authored-By: Claude Opus 5 (1M context) --- ...420000000000-BulkTemplateTradeDirection.ts | 69 +++++++++ .../bulk-template-direction.spec.ts | 134 ++++++++++++++++++ .../contract-templates.controller.ts | 2 +- .../contract-templates.repository.ts | 28 +++- .../contract-templates.service.ts | 122 +++++++++++----- .../dto/contract-template.dto.ts | 19 ++- .../entities/contract-template.entity.ts | 52 ++++++- .../ContractTemplatesPage.tsx | 78 +++++++--- .../services/contract-templates.service.ts | 15 +- 9 files changed, 448 insertions(+), 71 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts diff --git a/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts b/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts new file mode 100644 index 000000000..e76591205 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bulk contract templates gain trade direction, so the unique key becomes + * (cargo type, direction, customs option) instead of (cargo type, customs). + * + * Intercity is domestic and crosses no border, so it has no customs variant at + * all: with_customs stays NULL there, enforced by ck_bulk_intercity_no_customs. + * The unique index coalesces that NULL so two intercity templates for the same + * cargo type still collide (plain NULLs never do). + * + * No backfill: staff-created bulk templates are keyed by cargo_type_id and no + * such row exists yet — the seeded direction-keyed bulk rows were retired by + * 3320000000000 and carry a NULL cargo_type_id. The five system container + * templates are untouched: cargo_type_id IS NULL keeps them out of both the + * index and the check. + */ +export class BulkTemplateTradeDirection3420000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS trade_direction varchar(20) + `); + + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`, + ); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs + ON freight.contract_templates + (cargo_type_id, trade_direction, COALESCE(with_customs, false)) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK ( + cargo_type_id IS NULL + OR ( + trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY') + AND (trade_direction = 'INTERCITY') = (with_customs IS NULL) + ) + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`, + ); + 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 + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates DROP COLUMN IF EXISTS trade_direction + `); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts new file mode 100644 index 000000000..b822387da --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts @@ -0,0 +1,134 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractTemplatesRepository } from './contract-templates.repository'; +import { ContractTemplatesService } from './contract-templates.service'; +import { + bulkTemplateCode, + bulkTemplateDirectionFor, +} from './entities/contract-template.entity'; + +describe('bulkTemplateDirectionFor', () => { + it('maps the contract-side DOMESTIC onto the template-side INTERCITY', () => { + expect(bulkTemplateDirectionFor('DOMESTIC')).toBe('INTERCITY'); + expect(bulkTemplateDirectionFor('INTERCITY')).toBe('INTERCITY'); + expect(bulkTemplateDirectionFor(null)).toBe('INTERCITY'); + expect(bulkTemplateDirectionFor(undefined)).toBe('INTERCITY'); + expect(bulkTemplateDirectionFor('IMPORT')).toBe('IMPORT'); + expect(bulkTemplateDirectionFor('EXPORT')).toBe('EXPORT'); + }); +}); + +describe('bulkTemplateCode', () => { + it('gives each direction/customs combination its own code', () => { + expect(bulkTemplateCode('STEEL', 'IMPORT', true)).toBe('BULK_IMPORT_STEEL_CUSTOMS'); + expect(bulkTemplateCode('STEEL', 'IMPORT', false)).toBe( + 'BULK_IMPORT_STEEL_NO_CUSTOMS', + ); + expect(bulkTemplateCode('STEEL', 'EXPORT', true)).toBe('BULK_EXPORT_STEEL_CUSTOMS'); + expect(bulkTemplateCode('STEEL', 'EXPORT', false)).toBe( + 'BULK_EXPORT_STEEL_NO_CUSTOMS', + ); + }); + + it('leaves intercity unsuffixed — it crosses no border', () => { + expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL'); + }); + + it('produces 5 distinct codes per cargo type', () => { + const codes = [ + bulkTemplateCode('STEEL', 'IMPORT', true), + bulkTemplateCode('STEEL', 'IMPORT', false), + bulkTemplateCode('STEEL', 'EXPORT', true), + bulkTemplateCode('STEEL', 'EXPORT', false), + bulkTemplateCode('STEEL', 'INTERCITY', null), + ]; + expect(new Set(codes).size).toBe(5); + }); +}); + +describe('ContractTemplatesService bulk create/resolve', () => { + function build() { + const repository = { + findCargoType: jest.fn(() => + Promise.resolve({ + id: 'cargo-1', + code: 'STEEL', + cargoTypeName: 'Steel', + hasContractTemplate: true, + }), + ), + findByCargoCombo: jest.fn(() => Promise.resolve(null)), + findActiveBulkTemplate: jest.fn(() => Promise.resolve(null)), + findByCode: jest.fn(() => Promise.resolve(null)), + saveTemplate: jest.fn((template) => Promise.resolve(template)), + } as unknown as ContractTemplatesRepository; + return { + repository, + service: new ContractTemplatesService(repository, {} as never), + }; + } + + it('stores direction and customs on an import template', async () => { + const { service } = build(); + const created = await service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'EXPORT', + withCustoms: true, + }); + expect(created.code).toBe('BULK_EXPORT_STEEL_CUSTOMS'); + expect(created.tradeDirection).toBe('EXPORT'); + expect(created.withCustoms).toBe(true); + expect(created.documentTitle).toBe( + 'Steel Transportation and Customs Clearance Services', + ); + }); + + it('stores a null customs flag for intercity', async () => { + const { service } = build(); + const created = await service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'INTERCITY', + }); + expect(created.code).toBe('BULK_INTERCITY_STEEL'); + expect(created.withCustoms).toBeNull(); + expect(created.documentTitle).toBe('Steel Transportation Services'); + }); + + it('rejects a customs flag on intercity', async () => { + const { service } = build(); + await expect( + service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'INTERCITY', + withCustoms: false, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('requires a customs flag on import and export', async () => { + const { service } = build(); + await expect( + service.create({ cargoTypeId: 'cargo-1', tradeDirection: 'IMPORT' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => { + const { repository, service } = build(); + await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1'); + expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith( + 'cargo-1', + 'INTERCITY', + null, + ); + }); + + it('resolves an import bulk contract on direction and customs', async () => { + const { repository, service } = build(); + await service.findActiveForContract('IMPORT', 'BULK', false, 'cargo-1'); + expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith( + 'cargo-1', + 'IMPORT', + false, + ); + }); +}); 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 7053d224f..cce30c187 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 @@ -61,7 +61,7 @@ export class ContractTemplatesController { ]) @ApiOperation({ summary: - "Create a bulk contract template for a (cargo type, customs option) pair", + "Create a bulk contract template for a (cargo type, trade direction, customs option) combination", }) create(@Body() dto: CreateContractTemplateDto) { return this.service.create(dto); 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 9d50b1677..1df3b1302 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 @@ -1,10 +1,13 @@ import { BaseRepository } from "@edr/api-common"; import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { IsNull, Repository } from "typeorm"; import { CargoType } from "../rule-engine/entities/cargo-type.entity"; -import { ContractTemplate } from "./entities/contract-template.entity"; +import { + BulkTemplateDirection, + ContractTemplate, +} from "./entities/contract-template.entity"; @Injectable() export class ContractTemplatesRepository extends BaseRepository { @@ -28,24 +31,35 @@ export class ContractTemplatesRepository extends BaseRepository { - return this.repository.findOne({ where: { cargoTypeId, withCustoms } }); + return this.repository.findOne({ + where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() }, + }); } /** * 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). + * exclusive, so at most one row matches). Intercity templates carry no + * customs variant, so they are matched on a null flag. */ findActiveBulkTemplate( cargoTypeId: string, - withCustoms: boolean, + tradeDirection: BulkTemplateDirection, + withCustoms: boolean | null, ): Promise { return this.repository .createQueryBuilder("t") .where("t.is_active = true") - .andWhere("t.with_customs = :withCustoms", { withCustoms }) + .andWhere("t.trade_direction = :tradeDirection", { tradeDirection }) + .andWhere( + withCustoms === null + ? "t.with_customs IS NULL" + : "t.with_customs = :withCustoms", + withCustoms === null ? {} : { withCustoms }, + ) .andWhere( `(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = ( SELECT c.parent_group_id FROM freight.cargo_types c 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 da9c849f7..3b2e43cff 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 @@ -23,6 +23,9 @@ import { UpdateContractTemplateDto, } from "./dto/contract-template.dto"; import { + BulkTemplateDirection, + bulkTemplateCode, + bulkTemplateDirectionFor, CONTRACT_TEMPLATE_CODES, ContractTemplate, ContractTemplateArticle, @@ -77,10 +80,13 @@ export class ContractTemplatesService { } /** - * 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. + * Staff-created bulk template for one (cargo type, direction, customs + * option) triple. The cargo type must have hasContractTemplate enabled and + * the combination must not already exist — the same commodity + direction + + * customs pairing is edited, never duplicated. + * + * Intercity is domestic and crosses no border, so it carries no customs + * variant: the flag must be omitted and is stored as null. */ async create(dto: CreateContractTemplateDto): Promise { const cargoType = await this.repository.findCargoType(dto.cargoTypeId); @@ -92,31 +98,46 @@ export class ContractTemplatesService { `"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`, ); } - const variant = dto.withCustoms ? "with" : "without"; + + const direction = dto.tradeDirection; + const intercity = direction === "INTERCITY"; + if (intercity && dto.withCustoms !== undefined) { + throw new BadRequestException( + "Intercity contracts are domestic and cross no border — they have no customs clearing variant", + ); + } + if (!intercity && dto.withCustoms === undefined) { + throw new BadRequestException( + `A ${direction.toLowerCase()} template must state whether customs clearing is included`, + ); + } + const withCustoms = intercity ? null : Boolean(dto.withCustoms); + + const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms); const existing = await this.repository.findByCargoCombo( dto.cargoTypeId, - dto.withCustoms, + direction, + withCustoms, ); if (existing) { throw new ConflictException( - `A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`, + `${label} 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.code = bulkTemplateCode(cargoType.code, direction, withCustoms); + template.name = dto.name ?? label; template.description = dto.description ?? null; - template.documentTitle = dto.withCustoms + template.documentTitle = 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.tradeDirection = direction; + template.withCustoms = withCustoms; template.isSystem = false; try { return await this.repository.saveTemplate(template); @@ -124,13 +145,29 @@ export class ContractTemplatesService { // 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`, + `${label} already exists — edit that template instead`, ); } throw error; } } + /** Human label for one bulk combination, used for names and conflict errors. */ + private comboLabel( + cargoTypeName: string, + direction: BulkTemplateDirection, + withCustoms: boolean | null, + ): string { + const dir = direction.charAt(0) + direction.slice(1).toLowerCase(); + const customs = + withCustoms === null + ? "" + : withCustoms + ? ", with customs clearing" + : ", without customs clearing"; + return `${cargoTypeName} Bulk Contract (${dir}${customs})`; + } + /** Bulk templates only — the five seeded container templates are permanent. */ async remove(code: string): Promise { const template = await this.getByCode(code); @@ -146,9 +183,10 @@ export class ContractTemplatesService { * 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). + * (or its parent group), trade direction and customs option. A domestic + * contract resolves to the intercity template regardless of its customs flag. + * 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, @@ -159,9 +197,11 @@ export class ContractTemplatesService { const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); if (isBulk) { if (!cargoTypeId) return null; + const direction = bulkTemplateDirectionFor(tradeDirection); return this.repository.findActiveBulkTemplate( cargoTypeId, - Boolean(customsClearingEnabled), + direction, + direction === "INTERCITY" ? null : Boolean(customsClearingEnabled), ); } const code = contractTemplateCodeFor( @@ -274,18 +314,31 @@ export class ContractTemplatesService { /** * 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. + * templates aren't in the fixed code map — they preview against the bulk + * pack matching their own direction and customs option. */ private previewKeyFor(template: ContractTemplate): string { if (template.cargoTypeId) { - return template.withCustoms - ? "IMP_BULK_USD_FORWARDING" - : "IMP_BULK_USD_TRANSPORT_ONLY"; + const direction = bulkTemplateDirectionFor(template.tradeDirection); + const dir = + direction === "IMPORT" ? "IMP" : direction === "EXPORT" ? "EXP" : "DOM"; + const scope = template.withCustoms ? "FORWARDING" : "TRANSPORT_ONLY"; + return `${dir}_BULK_USD_${scope}`; } return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode]; } + /** + * Preview direction: bulk templates carry it on the row, the fixed container + * codes carry it as the code prefix. + */ + private previewDirectionFor(template: ContractTemplate): BulkTemplateDirection { + if (template.cargoTypeId) { + return bulkTemplateDirectionFor(template.tradeDirection); + } + return bulkTemplateDirectionFor(template.code.split("_")[0]); + } + private buildMockView( template: ContractTemplate, dynamicTemplate: ContractDynamicTemplateView, @@ -294,11 +347,12 @@ export class ContractTemplatesService { const previewKey = this.previewKeyFor(template); const meta = getTemplateMeta(previewKey); const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK"); + const direction = this.previewDirectionFor(template); const now = new Date(); // Representative rate schedule so the admin preview shows the live-rate // table shape. Real contracts populate this from freight.rates (LIVE). - const rateSchedule = this.mockRateSchedule(code, isBulk); + const rateSchedule = this.mockRateSchedule(direction, isBulk); return { bookingId: "00000000-0000-0000-0000-000000000000", @@ -336,11 +390,7 @@ export class ContractTemplatesService { schedule: { originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", destinationLabel: "Galaan Multipurpose Port (GMP)", - tradeDirection: code.startsWith("IMPORT") - ? "IMPORT" - : code.startsWith("EXPORT") - ? "EXPORT" - : "DOMESTIC", + tradeDirection: direction === "INTERCITY" ? "DOMESTIC" : direction, freightType: isBulk ? "BULK" : "CONTAINER", serviceType: "Rail transport and customs clearance", scheduledDate: "—", @@ -379,16 +429,14 @@ export class ContractTemplatesService { } /** Static, representative rate schedule for the admin preview only. */ - private mockRateSchedule(code: string, isBulk: boolean): RateSchedule { - const dir = code.startsWith("IMPORT") - ? "import" - : code.startsWith("EXPORT") - ? "export" - : "domestic"; + private mockRateSchedule( + direction: BulkTemplateDirection, + isBulk: boolean, + ): RateSchedule { const lane = - dir === "export" + direction === "EXPORT" ? "Galaan Multipurpose Port → SGTD" - : dir === "domestic" + : direction === "INTERCITY" ? "Mojo Dry Port → Dire Dawa" : "Negad → Mojo Dry Port"; 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 c8911d8e5..9f7579dbd 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 @@ -3,6 +3,7 @@ import { Type } from "class-transformer"; import { IsArray, IsBoolean, + IsIn, IsInt, IsOptional, IsString, @@ -13,6 +14,11 @@ import { ValidateNested, } from "class-validator"; +import { + BULK_TEMPLATE_DIRECTIONS, + BulkTemplateDirection, +} from "../entities/contract-template.entity"; + export class CreateContractTemplateDto { @ApiProperty({ description: @@ -23,10 +29,19 @@ export class CreateContractTemplateDto { cargoTypeId!: string; @ApiProperty({ - description: "Whether this is the with-customs-clearing variant", + description: "Trade direction this template is written for", + enum: BULK_TEMPLATE_DIRECTIONS, }) + @IsIn(BULK_TEMPLATE_DIRECTIONS as unknown as string[]) + tradeDirection!: BulkTemplateDirection; + + @ApiPropertyOptional({ + description: + "Whether this is the with-customs-clearing variant. Required for IMPORT/EXPORT, rejected for INTERCITY (domestic movements cross no border)", + }) + @IsOptional() @IsBoolean() - withCustoms!: boolean; + withCustoms?: boolean; @ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" }) @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 af0721572..e070aa1e4 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 @@ -9,10 +9,10 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; * 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. + * (`cargoTypeId`), trade direction (`tradeDirection`) and customs option + * (`withCustoms`), one template per combination. Their codes are generated by + * `bulkTemplateCode` below. 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 @@ -82,9 +82,41 @@ export function contractTemplateCodeFor( return `${direction}_${freight}_${customs}` as ContractTemplateCode; } +/** The three directions a bulk template can be written for. */ +export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const; + +export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number]; + +/** + * Contracts store DOMESTIC for intercity movements; templates use INTERCITY. + * Anything that is not an explicit IMPORT/EXPORT is domestic, matching + * `contractTemplateCodeFor`. + */ +export function bulkTemplateDirectionFor( + tradeDirection?: string | null, +): BulkTemplateDirection { + const value = (tradeDirection ?? "").toUpperCase(); + return value === "IMPORT" || value === "EXPORT" ? value : "INTERCITY"; +} + +/** + * Generated code for a staff-created bulk template. Intercity gets no customs + * suffix — it crosses no border, so the variant does not exist. + */ +export function bulkTemplateCode( + cargoCode: string, + direction: BulkTemplateDirection, + withCustoms: boolean | null, +): string { + const suffix = + direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS"; + return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase(); +} + @Entity({ schema: "freight", name: "contract_templates" }) // Uniqueness lives in partial DB indexes (live rows only): code, and -// (cargo_type_id, with_customs) for staff-created bulk templates. +// (cargo_type_id, trade_direction, coalesce(with_customs,false)) for +// staff-created bulk templates. @Index(["code"]) export class ContractTemplate extends BaseEntity { @Column({ name: "code", type: "varchar", length: 80 }) @@ -118,7 +150,15 @@ export class ContractTemplate extends BaseEntity { @JoinColumn({ name: "cargo_type_id" }) cargoType?: CargoType | null; - /** Bulk templates only: whether this is the with-customs-clearing variant. */ + /** Bulk templates only: IMPORT, EXPORT or INTERCITY. */ + @Column({ name: "trade_direction", type: "varchar", length: 20, nullable: true }) + tradeDirection?: BulkTemplateDirection | null; + + /** + * Bulk templates only: whether this is the with-customs-clearing variant. + * Always null for INTERCITY templates — domestic movements have no customs + * leg, so neither variant applies. + */ @Column({ name: "with_customs", type: "boolean", nullable: true }) withCustoms?: boolean | null; diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index 1a66bbdd5..1adcd2e6c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -38,7 +38,10 @@ import { } from "@/hooks/contract-templates/useContractTemplates"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { cargoTypesService } from "@/services/cargo-types.service"; -import type { ContractTemplate } from "@/services/contract-templates.service"; +import type { + BulkTemplateDirection, + ContractTemplate, +} from "@/services/contract-templates.service"; import TemplatePreviewModal from "./TemplatePreviewModal"; const DIRECTION_LABEL: Record = { @@ -68,6 +71,14 @@ function customsVariant(template: ContractTemplate): boolean | null { return null; } +// Bulk templates carry the direction on the row; the fixed container codes +// carry it as the code prefix. +function directionOf(template: ContractTemplate): string { + return isBulk(template) + ? template.tradeDirection ?? "INTERCITY" + : template.code.split("_")[0]; +} + function formatUpdated(value: string): string { return new Date(value).toLocaleDateString("en-GB", { day: "numeric", @@ -105,7 +116,7 @@ export default function ContractTemplatesPage() { void; onCreated: (code: string) => void; }) { + const [direction, setDirection] = useState("IMPORT"); const [withCustoms, setWithCustoms] = useState("true"); const [cargoTypeId, setCargoTypeId] = useState(null); const create = useCreateContractTemplate(); + const intercity = direction === "INTERCITY"; const { data: cargoTypes, isLoading } = useQuery({ queryKey: ["cargo-types", "contract-template-options"], @@ -238,19 +255,42 @@ function CreateTemplateModal({
- Customs clearing + Trade direction setDirection(value as BulkTemplateDirection)} data={[ - { value: "true", label: "With customs clearing" }, - { value: "false", label: "Without customs clearing" }, + { value: "IMPORT", label: "Import" }, + { value: "EXPORT", label: "Export" }, + { value: "INTERCITY", label: "Intercity" }, ]} />
+ {intercity ? ( + + Intercity contracts are domestic and cross no border, so they have + no customs clearing variant — one template per cargo type. + + ) : ( +
+ + Customs clearing + + +
+ )} + setNewEmail(e.target.value)} + /> + {formError &&

{formError}

} + + ) : ( +
+ + setOtp(e.target.value)} + /> + {formError &&

{formError}

} +
+ )} + + + + {step === "enterEmail" ? ( + + ) : ( + + )} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx new file mode 100644 index 000000000..fb5f9086e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { KeyRound, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in backoffice user change their own password. The account + * is logged out on success — the old token was issued under the old + * password, and this forces a clean re-login rather than trusting the + * server to keep the existing session valid. + */ +export function ChangePasswordCard() { + const { logout } = useAuth(); + const changePasswordMutation = useMutation( + api.account.changePassword.mutationOptions(), + ); + + const [open, setOpen] = useState(false); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [formError, setFormError] = useState(""); + + const closeDialog = () => { + setOpen(false); + setOldPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setFormError(""); + }; + + const submit = () => { + setFormError(""); + + if (!oldPassword || !newPassword || !confirmPassword) { + setFormError("All fields are required."); + return; + } + if (newPassword.length < 8) { + setFormError("New password must be at least 8 characters."); + return; + } + if (newPassword === oldPassword) { + setFormError("New password must be different from the current one."); + return; + } + if (newPassword !== confirmPassword) { + setFormError("New password and confirmation do not match."); + return; + } + + changePasswordMutation.mutate( + { oldPassword, newPassword, confirmPassword }, + { + onSuccess: () => { + toast.success("Password changed. Please sign in again."); + closeDialog(); + setTimeout(logout, 1200); + }, + }, + ); + }; + + return ( + + + + + Password + + Change the password for your account. + + + + + + !next && closeDialog()}> + + + Change password + + You'll be signed out and asked to log in again once it's changed. + + +
+
+ + setOldPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+ {formError &&

{formError}

} +
+ + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx index 708a5a3ca..c7bd1b197 100644 --- a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx +++ b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx @@ -319,7 +319,7 @@ export const TopBar = () => { {t("header.viewProfile")} navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5"> {t("header.changePassword")} diff --git a/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx b/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx index ed569a83d..46ddcfe7d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx @@ -502,7 +502,7 @@ const Header = () => { navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} > diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx index baf548d92..37f91909e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx @@ -1,8 +1,12 @@ import { MySignatureCard } from "@/components/profile/MySignatureCard"; +import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard"; +import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard"; export default function MyProfilePage() { return (
+ +
diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx index ff7678856..4f58ae6b7 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx @@ -551,7 +551,7 @@ const Top: React.FC = ({ navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} > {t("header.changePassword")} diff --git a/apps/edr-freight-web/backoffice/src/services/account.service.ts b/apps/edr-freight-web/backoffice/src/services/account.service.ts new file mode 100644 index 000000000..40511bf79 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/account.service.ts @@ -0,0 +1,50 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; + +export type ContactChannel = "email" | "phone"; + +export interface ChangePasswordPayload { + oldPassword: string; + newPassword: string; + confirmPassword: string; +} + +export interface SendContactOtpPayload { + channel: ContactChannel; + /** The NEW email/phone to verify — the OTP is sent here, not to the current one. */ + value: string; +} + +export interface UpdateContactPayload extends SendContactOtpPayload { + otp: string; +} + +export const accountService = { + /** PATCH /auth/change-password — generic IAM route, works for any user type. */ + changePassword: async (payload: ChangePasswordPayload): Promise => { + const response = await client.patch("/auth/change-password", payload); + unwrap(response.data); + }, + + /** POST /me/contact/otp — sends a code to the new email/phone to prove ownership. */ + sendContactOtp: async ( + payload: SendContactOtpPayload, + ): Promise<{ sentTo: string }> => { + const response = await client.post<{ sentTo: string }>( + "/me/contact/otp", + payload, + ); + return unwrap(response.data); + }, + + /** PATCH /me/contact — verifies the OTP and writes the new email/phone. */ + updateContact: async ( + payload: UpdateContactPayload, + ): Promise<{ success: true; value: string }> => { + const response = await client.patch<{ success: true; value: string }>( + "/me/contact", + payload, + ); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 6c9c69788..2a1963f8d 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -136,6 +136,12 @@ import type { WarehouseZone, } from "@/types/warehouse"; import { endpoint } from "@/utils/endpoint"; +import { + accountService, + type ChangePasswordPayload, + type SendContactOtpPayload, + type UpdateContactPayload, +} from "./account.service"; import { BookingListFilter, bookingsService, @@ -2182,6 +2188,29 @@ export const api = { ), }, + account: { + changePassword: endpoint( + "me", + "change-password", + (payload) => accountService.changePassword(payload), + ), + + sendContactOtp: endpoint( + "me", + "send-contact-otp", + (payload) => accountService.sendContactOtp(payload), + ), + + updateContact: endpoint< + UpdateContactPayload, + { success: true; value: string } + >( + "me", + "update-contact", + (payload) => accountService.updateContact(payload), + ), + }, + signatures: { mySignature: endpoint( "me", From dd39c2be7c220f0eac96b2d2d944815005a965fc Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 13:29:31 +0000 Subject: [PATCH 20/25] baseLiters --- .../services/rate-change-requests.service.spec.ts | 13 +++++++++++++ .../services/rate-change-requests.service.ts | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts index be7c8984c..f8617cd65 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -118,6 +118,19 @@ describe('RateChangeRequestsService', () => { expect(request.payload).toEqual({ destinationYardId: 'yard-c' }); }); + it('carries baseLiters — a switch to PER_LITER keeps its billing base', async () => { + const { service } = build({ + rate: liveRate({ rateUnit: 'PER_WAGON', baseLiters: null }), + }); + + const request = await service.submit({ + rateId: 'rate-1', + update: { rateUnit: 'PER_LITER', baseLiters: 3 }, + }); + + expect(request.payload).toEqual({ rateUnit: 'PER_LITER', baseLiters: 3 }); + }); + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { const { service } = build(); await expect( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 8cc97f343..8913c9ef9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -42,6 +42,10 @@ const DIFFABLE_FIELDS = [ // LIVE last-mile rate would diff to "nothing changed". 'minKm', 'maxKm', + // PER_LITER fuel surcharge billing base. Missing here, a switch to PER_LITER + // dropped the submitted liters and validation failed with "needs a base + // liters amount" even though the payload carried one. + 'baseLiters', ] as const; /** From ff3cee12e316b759daa75aa67fcde5bc5c724cad Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 13:47:53 +0000 Subject: [PATCH 21/25] feat: fuel surcharge per lane and cargo type --- .../contract-rate-schedule.builder.ts | 15 ++++++++++--- .../contracts/contract-pricing.service.ts | 17 ++++++++------ .../rule-engine/rule-engine.service.spec.ts | 9 +++++--- .../rule-engine/rule-engine.service.ts | 22 ++++++++++++------- 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index c644d6117..2f1991df2 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -193,17 +193,26 @@ export class ContractRateScheduleBuilder { return rate.tradeDirection === want; } - /** Fuel row — the lane matters, so it rides along in the charge label. */ + /** + * Fuel row — the lane matters, so it rides along in the charge label. + * Per-liter collapses to one flat total (base liters × rate value); the + * customer only ever sees the final price. + */ private fuelRow(rate: Rate): RateScheduleRow { const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; const destination = rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + const perLiter = rate.rateUnit === 'PER_LITER'; return { route: `Fuel surcharge (${origin} → ${destination})`, cargo: this.cargoLabel(rate), currency: rate.currency, - amount: this.formatAmount(rate.rateValue), - unit: this.unitLabel(rate.rateUnit), + amount: this.formatAmount( + perLiter + ? Number(rate.baseLiters ?? 0) * Number(rate.rateValue) + : rate.rateValue, + ), + unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit), }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 3f49b127f..af0a149d7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -302,15 +302,18 @@ export class ContractPricingService { r.cargoTypeId === scope.cargoTypeId, ); if (fuel && Number(fuel.rateValue) > 0) { - const base = Number(fuel.baseLiters ?? 0); + // Per-liter collapses to one flat total (base liters × rate value) — + // the customer only sees the final price, and booking pricing bills + // the same flat figure once (see RuleEngineService.fuelCharges). + const perLiter = fuel.rateUnit === 'PER_LITER'; + const total = perLiter + ? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue) + : Number(fuel.rateValue); lineItems.push({ code: 'FUEL_SURCHARGE', - label: - fuel.rateUnit === 'PER_LITER' - ? `Fuel surcharge (${scope.cargoType.cargoTypeName}, ${base} liters)` - : `Fuel surcharge (${scope.cargoType.cargoTypeName})`, - unit: toContractUnit(fuel.rateUnit), - unitPrice: convert(Number(fuel.rateValue)), + label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`, + unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit), + unitPrice: convert(total), cargoTypeCode: scope.cargoType.code ?? null, conditionalOn: 'has_fuel', }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 440616298..281f2d1b2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -475,13 +475,16 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => { const fuelMods = (result: Awaited>) => result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE'); - it('PER_LITER bills base liters × rate value once, regardless of wagons', async () => { + it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => { const result = await buildService([fuelPerLiter]).evaluate(fuelInput()); const mods = fuelMods(result); expect(mods).toHaveLength(1); - expect(mods[0].triggerValue).toBe(100); + // Flat: the customer sees only the total, and a frozen contract snapshot + // (also stored flat) multiplies it by quantity 1 — never by the liters. + expect(mods[0].triggerValue).toBe(1); + expect(mods[0].unitPriceUsd).toBe(200); expect(mods[0].calculatedAmount).toBe(200); - expect(mods[0].billingUnit).toBe('PER_LITER'); + expect(mods[0].billingUnit).toBe('FLAT'); }); it('PER_WAGON bills the wagons the cargo occupies', async () => { diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index a290aeb33..908d00ecc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -648,9 +648,12 @@ export class RuleEngineService { /** * Fuel surcharge — fires when the booking's cargo type has hasFuel = true, * billed off the FUEL rate matching the booking's lane (trade direction + - * origin + destination) and cargo type. PER_LITER bills baseLiters × - * rateValue once per booking; PER_WAGON bills the wagons the cargo occupies. - * No matching lane rate simply bills nothing — same leniency as lashing. + * origin + destination) and cargo type. PER_LITER collapses to one FLAT + * amount (baseLiters × rateValue, once per booking) — the customer only ever + * sees the total, and the frozen contract snapshot stores that same flat + * figure so the snapshot-override math bills it exactly once. PER_WAGON + * bills the wagons the cargo occupies. No matching lane rate simply bills + * nothing — same leniency as lashing. */ private fuelCharges( input: BookingEvaluationInput, @@ -673,9 +676,12 @@ export class RuleEngineService { 0, Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0), ); - const billedQty = - rate.rateUnit === 'PER_LITER' ? Number(rate.baseLiters ?? 0) : wagons; - const amount = billedQty * rateValue; + const perLiter = rate.rateUnit === 'PER_LITER'; + const billedQty = perLiter ? 1 : wagons; + const unitPrice = perLiter + ? Number(rate.baseLiters ?? 0) * rateValue + : rateValue; + const amount = billedQty * unitPrice; if (!(amount > 0)) return modifiers; modifiers.push({ rateId: rate.id, @@ -683,8 +689,8 @@ export class RuleEngineService { triggerValue: billedQty, calculatedAmount: amount, currency: rate.currency, - unitPriceUsd: rateValue, - billingUnit: rate.rateUnit, + unitPriceUsd: unitPrice, + billingUnit: perLiter ? 'FLAT' : rate.rateUnit, }); return modifiers; } From 5c2afe3454a3fc2ff30eaa9174415e8c2b8cb8dd Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 12 Aug 2026 07:30:28 +0000 Subject: [PATCH 22/25] fix(notifications): guard emitNew/emitUnreadCount against no WS server @WebSocketServer() only wires `server` once the WS adapter attaches to a running HTTP listener. It never does under NestFactory.createApplicationContext (scripts, one-off jobs) -- confirmed live tonight, when the EIMS self-test registration's failure alert crashed with "Cannot read properties of null (reading 'to')" instead of just logging that no socket was available. The registration result itself was unaffected (postSigned already resolved, the EimsApiException was correctly re-thrown), but the crash happened inside an await'd call in the same chain -- in a context where it wasn't caught, it would have masked whatever result the caller actually cared about. Both push methods now skip and log at debug level when no server is attached, since the notification row is already persisted by the time they're called -- a missing socket just means "no live push this time", not a reason to lose the caller's own outcome. `server` drops its `!` non-null assertion to match. Co-Authored-By: Claude Opus 5 (1M context) --- .../notifications.gateway.spec.ts | 29 +++++++++++++++++++ .../notifications.gateway.ts | 18 +++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts new file mode 100644 index 000000000..bc79d8acb --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts @@ -0,0 +1,29 @@ +import { NotificationsGateway } from "./notifications.gateway"; +import { WsAuthService } from "./ws-auth.service"; + +const gateway = () => new NotificationsGateway({} as WsAuthService); + +describe("NotificationsGateway", () => { + it("skips emitNew rather than throwing when no WebSocket server is attached", () => { + const g = gateway(); + expect(() => g.emitNew("user-1", { id: "n-1" } as never, 3)).not.toThrow(); + }); + + it("skips emitUnreadCount rather than throwing when no WebSocket server is attached", () => { + const g = gateway(); + expect(() => g.emitUnreadCount("user-1", 3)).not.toThrow(); + }); + + it("pushes to the user's room once a server is attached", () => { + const g = gateway(); + const emit = jest.fn(); + const to = jest.fn().mockReturnValue({ emit }); + (g as unknown as { server: { to: typeof to } }).server = { to }; + + g.emitNew("user-1", { id: "n-1" } as never, 3); + + expect(to).toHaveBeenCalledWith("user:user-1"); + expect(emit).toHaveBeenCalledWith("notification:new", { id: "n-1" }); + expect(emit).toHaveBeenCalledWith("notification:unread-count", 3); + }); +}); diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts index 0c4704170..4658dfac0 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -27,8 +27,10 @@ import { WsAuthService } from "./ws-auth.service"; export class NotificationsGateway implements OnGatewayConnection { private readonly logger = new Logger(NotificationsGateway.name); + // Not `!`-asserted: Nest only wires this once the WS adapter attaches to a running HTTP + // listener, which does not happen under `NestFactory.createApplicationContext` — see `skip()`. @WebSocketServer() - private readonly server!: Server; + private readonly server?: Server; constructor(private readonly wsAuth: WsAuthService) {} @@ -45,6 +47,7 @@ export class NotificationsGateway implements OnGatewayConnection { /** Push a freshly-created notification + the new unread count to a user. */ emitNew(userId: string, notification: NotificationDto, unreadCount: number): void { + if (!this.server) return this.skip("emitNew"); const room = this.server.to(this.room(userId)); room.emit(NOTIFICATION_WS_EVENTS.NEW, notification); room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); @@ -52,11 +55,24 @@ export class NotificationsGateway implements OnGatewayConnection { /** Push only an updated unread count (e.g. after a read on another tab). */ emitUnreadCount(userId: string, unreadCount: number): void { + if (!this.server) return this.skip("emitUnreadCount"); this.server .to(this.room(userId)) .emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); } + /** + * `@WebSocketServer()` only wires `server` once the WS adapter attaches to a running HTTP + * listener — never under `NestFactory.createApplicationContext` (scripts, one-off jobs), and not + * for the brief window before `app.listen()` completes in a real boot either. The notification row + * is already persisted by this point (the caller writes it before pushing), so a missing socket + * server just means "no live push this time" — skip it rather than throw and lose the caller's + * own result (e.g. an EIMS registration outcome that already succeeded or failed for real). + */ + private skip(method: string): void { + this.logger.debug(`${method}: no WebSocket server attached (non-HTTP context?) — push skipped`); + } + private room(userId: string): string { return `user:${userId}`; } From 8d53dc17ce997b52c7d5b8d703105c2290f660a0 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 12 Aug 2026 08:08:03 +0000 Subject: [PATCH 23/25] docs(eims): confirm signing algorithm and cert format against MoR's guide Cross-checked our RSA-SHA512 signing and raw-bytes certificate encoding against MoR's own "Guide to Generating and Using Certificate for E-Invoicing" (supplied today). Both were previously documented as our best inference from the Postman collection; the guide names SHA512withRSA explicitly (PKCS#1v1.5, matching Node's createSign default) and its own worked example certificate is byte-for-byte the same Subject:/Issuer: + 3-cert PEM chain text-file format ours is. No behavior change -- the comment now says confirmed, not assumed. Field order, section names, date format and the {request, signature, certificate} envelope in the guide's worked example all match our mapper exactly (order doesn't matter per the guide, but it's a further concordance check). The one guide/live disagreement -- its example shows "NatureOfSupplies": "Goods" where our actual 400 SCHEMA ERROR demanded lowercase "goods"/"service" -- is left as-is: the live, machine-generated schema error outranks a static doc example that may predate a schema change. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/eims/eims-signer.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts index babec6b44..b91f306ab 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts @@ -8,9 +8,14 @@ import { EimsSignedRequest } from "./eims.types"; * * 1. compact `JSON.stringify` of the **inner** request object only, * 2. those exact UTF-8 bytes, - * 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding), + * 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding). Confirmed, not + * assumed: MoR's own "Guide to Generating and Using Certificate for E-Invoicing" names + * `SHA512withRSA` explicitly, which is PKCS#1v1.5 in Java (PSS would be named + * `SHA512withRSAandMGF1`) — the same padding `createSign("RSA-SHA512")` uses by default. * 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key), - * 5. base64 of the certificate file's exact bytes. + * 5. base64 of the certificate file's exact bytes. Also confirmed by the same guide: its own + * worked example certificate is the identical `Subject:`/`Issuer:` header + 3-cert PEM chain + * text-file format ours is, base64'd with no re-encoding. * * The outer `{request, signature, certificate}` envelope is never itself signed, and the request * object is never mutated after serialization. From 2d4da8110b2fe0bbbbba86ea41892e6b65861638 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 12 Aug 2026 14:08:28 +0000 Subject: [PATCH 24/25] eims integration master test complete --- apps/edr-freight-api/.env.example | 13 +- .../edr-freight-api/src/config/eims.config.ts | 28 ++ .../migrations/3450000000000-WidenEimsIrn.ts | 33 +++ .../3460000000000-AddEimsSignedQr.ts | 20 ++ .../3470000000000-EimsCancellation.ts | 26 ++ .../3480000000000-WidenPreviousIrn.ts | 24 ++ .../migrations/3490000000000-EimsReceipts.ts | 34 +++ .../modules/billing/billing.service.spec.ts | 108 ++++++++ .../src/modules/billing/billing.service.ts | 77 ++++-- .../invoice-document.service.spec.ts | 46 ++++ .../documents/invoice-document.service.ts | 34 ++- .../billing/eims-invoice.mapper.spec.ts | 22 +- .../modules/billing/eims-invoice.mapper.ts | 16 +- .../billing/entities/invoice.entity.ts | 36 ++- .../eims/dto/cancel-eims-registration.dto.ts | 19 ++ .../eims/dto/register-sales-receipt.dto.ts | 88 ++++++ .../dto/register-withholding-receipt.dto.ts | 40 +++ .../eims/dto/resolve-eims-registration.dto.ts | 4 +- .../eims/eims-cancellation.service.spec.ts | 153 +++++++++++ .../modules/eims/eims-cancellation.service.ts | 117 ++++++++ .../modules/eims/eims-invoice-context.spec.ts | 118 ++++++++ .../src/modules/eims/eims-invoice-context.ts | 66 ++++- .../eims-invoice-registration.service.spec.ts | 109 +++++++- .../eims/eims-invoice-registration.service.ts | 128 +++++++-- .../modules/eims/eims-invoice-view.util.ts | 27 ++ .../modules/eims/eims-invoice.controller.ts | 55 +++- .../modules/eims/eims-receipt.service.spec.ts | 231 ++++++++++++++++ .../src/modules/eims/eims-receipt.service.ts | 259 ++++++++++++++++++ .../src/modules/eims/eims-receipt.types.ts | 97 +++++++ .../modules/eims/eims-registration.types.ts | 30 ++ .../src/modules/eims/eims-test-fixtures.ts | 6 + .../src/modules/eims/eims.module.ts | 17 +- .../eims/entities/eims-receipt.entity.ts | 68 +++++ .../eims/entities/eims-system-state.entity.ts | 7 +- .../services/train-scheduling.service.ts | 71 +++++ .../src/seed/freight-permissions.registry.ts | 24 +- .../backoffice/src/types/eims.ts | 25 +- pnpm-lock.yaml | 167 +++++------ 38 files changed, 2270 insertions(+), 173 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts create mode 100644 apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts create mode 100644 apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts create mode 100644 apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts create mode 100644 apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts create mode 100644 apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/cancel-eims-registration.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/register-withholding-receipt.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-view.util.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts create mode 100644 apps/edr-freight-api/src/modules/eims/entities/eims-receipt.entity.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 2c636eee4..5a145a0db 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -172,14 +172,23 @@ 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. -# Required, and deliberately unset: the choice is a tax position, not a default. +# Required, and deliberately unset here: 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). +# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env. EIMS_TAX_CODE= EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 +# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a +# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material. +# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above. +# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types. +EIMS_TAX_CODE_BY_CHARGE_TYPE= +EIMS_TAX_RATE_BY_CHARGE_TYPE= +# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively. +EIMS_EXCISE_BY_CHARGE_TYPE= +EIMS_DISCOUNT_BY_CHARGE_TYPE= # Document classification and payment presentation. EIMS_TRANSACTION_TYPE=B2B # Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'. diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index a8a929ca5..cf77072e0 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -89,8 +89,30 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to + * `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax + * treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above + * cannot express that. Values are raw strings; the context builder parses/validates them. + */ + taxCodeByChargeType: Record; + taxRateByChargeType: Record; + /** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge + * types not listed fall back to `exciseTaxValue` / 0 respectively. */ + exciseByChargeType: Record; + discountByChargeType: Record; cashierName: string | null; salesPersonName: string | null; + /** + * TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every + * buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be + * one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead + * of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits. + * Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists. + */ + buyerIdType: string | null; + buyerIdNumber: string | null; } const REQUIRED_VARS = [ @@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => { buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), + taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), + exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), + discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE), cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, + buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null, + buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null, }, }; diff --git a/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts new file mode 100644 index 000000000..ec3faf9eb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed + * live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as + * `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-` + * prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already + * accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING` + * and the system-wide reservation stuck in-flight with no block/alert (see + * `EimsInvoiceRegistrationService` for the accompanying code fix). + * + * Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or + * length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's + * real production shape — guessing another fixed bound risks the exact same failure again. + */ +export class WidenEimsIrn3450000000000 implements MigrationInterface { + name = "WidenEimsIrn3450000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE text + `); + } + + /** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts new file mode 100644 index 000000000..dedf2d05f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */ +export class AddEimsSignedQr3460000000000 implements MigrationInterface { + name = "AddEimsSignedQr3460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_signed_qr text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_signed_qr + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts new file mode 100644 index 000000000..7092e5518 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */ +export class EimsCancellation3470000000000 implements MigrationInterface { + name = "EimsCancellation3470000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8), + ADD COLUMN IF NOT EXISTS eims_cancellation_remark text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_cancelled_at, + DROP COLUMN IF EXISTS eims_cancellation_date, + DROP COLUMN IF EXISTS eims_cancellation_reason_code, + DROP COLUMN IF EXISTS eims_cancellation_remark + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts new file mode 100644 index 000000000..15a343f06 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too + * (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same + * varchar(64) on the next successful registration. + */ +export class WidenPreviousIrn3480000000000 implements MigrationInterface { + name = "WidenPreviousIrn3480000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts new file mode 100644 index 000000000..3d5ab02e9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `freight.eims_receipts` — see `EimsReceipt` entity. */ +export class EimsReceipts3490000000000 implements MigrationInterface { + name = "EimsReceipts3490000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_receipts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id uuid NOT NULL REFERENCES freight.invoices(id), + kind varchar(16) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + receipt_number varchar(64) NOT NULL, + rrn text, + qr text, + ack_status varchar(8), + submitted_at timestamptz, + last_error jsonb, + request jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 576c7b166..d9eef5a43 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); }); @@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); return { service, mg, events }; } @@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); return { service, mg, events }; } @@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, + {} as never, // config ); return { service, defaultManager, txManager, transaction }; }; @@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, + {} as never, // config ); return { service, manager }; }; @@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, + {} as never, // config ); return { service, repo }; }; @@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, + {} as never, // config ); return { service, repo }; }; @@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => }); }); }); + +describe("BillingService.document", () => { + const invoiceRow = (over: Record = {}) => ({ + id: "inv-1", + invoiceNumber: "INV-20260812-00001", + source: "booking", + sourceId: "booking-1", + status: Freight.InvoiceStatus.Pending, + type: "freight", + currency: "ETB", + subtotalAmount: 100, + taxAmount: 0, + totalAmount: 100, + paidAmount: 0, + balanceAmount: 100, + issuedAt: new Date(2026, 7, 12), + dueAt: new Date(2026, 7, 19), + eimsIrn: null, + eimsSignedQr: null, + company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" }, + ...over, + }); + + const build = (invoice: Record) => { + const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); + const service = new BillingService( + {} as never, + { findById: jest.fn().mockResolvedValue(invoice) } as never, + { findAll: jest.fn().mockResolvedValue([]) } as never, + {} as never, + {} as never, + {} as never, + { render } as never, + {} as never, + { + get: (key: string) => + key === "eims" + ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } + : undefined, + } as never, // config + ); + return { service, render }; + }; + + it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined(); + expect(model.qrImageUrl).toBeNull(); + }); + + it("shows the buyer's name, TIN and VAT number on every invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" }); + expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" }); + expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" }); + }); + + it("omits the VAT row when the buyer company has none", async () => { + const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } })); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined(); + }); + + it("shows EDR's own seller TIN and VAT number from EIMS config", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" }); + expect(model.summary).toContainEqual({ + label: "Seller VAT No.", + value: "43256663343256663322", + }); + }); + + it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => { + const { service, render } = build( + invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }), + ); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); + expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 6c7e8455d..caa25fbaa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,4 +1,5 @@ import { Freight, PaymentReferenceType } from "@edr/types"; +import { ConfigService } from "@nestjs/config"; import { BadRequestException, forwardRef, @@ -12,6 +13,7 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { FilesService } from "../files/files.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; @@ -161,6 +163,7 @@ export class BillingService { private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, private readonly files: FilesService, + private readonly config: ConfigService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -384,7 +387,7 @@ export class BillingService { async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "INVOICE"), + await this.toDocumentModel(invoice, "INVOICE"), ); } @@ -397,15 +400,24 @@ export class BillingService { ); } return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "RECEIPT"), + await this.toDocumentModel(invoice, "RECEIPT"), ); } + /** + * `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the + * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), + * not a payload we encode ourselves. Wrapped in a data URL, nothing more. + */ + private renderEimsQr(signedQr: string): string { + return `data:image/png;base64,${signedQr}`; + } + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ - private toDocumentModel( + private async toDocumentModel( invoice: Invoice & { lines: InvoiceLine[] }, kind: "INVOICE" | "RECEIPT", - ): InvoiceDocumentModel { + ): Promise { const title = invoice.source ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) : "EDR"; @@ -423,6 +435,43 @@ export class BillingService { totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + const summary: InvoiceDocumentModel["summary"] = [ + // Buyer identity — was missing entirely; a MoR-registered invoice must show who it was + // filed against, not just the seller. VatNumber shown only when the company has one. + { label: "Buyer", value: invoice.company?.name ?? null }, + { label: "Buyer TIN", value: invoice.company?.tin ?? null }, + ...(invoice.company?.vatNumber + ? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }] + : []), + { label: "Status", value: invoice.status }, + { label: "Type", value: invoice.type }, + { label: "Reference", value: invoice.sourceId }, + { label: "Currency", value: invoice.currency }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, + ]; + + // Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this + // codebase). Shown only when actually configured, same as the buyer VAT row. + const eimsCfg = this.config.get("eims"); + if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin }); + if (eimsCfg?.invoice?.sellerVatNumber) { + summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber }); + } + + // MoR EIMS reference — only once actually registered, never a placeholder row. + if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + return { kind, title, @@ -430,24 +479,7 @@ export class BillingService { issuedAt: invoice.issuedAt ?? invoice.createdAt, status: invoice.status, currency: invoice.currency, - summary: [ - { label: "Status", value: invoice.status }, - { label: "Type", value: invoice.type }, - { label: "Reference", value: invoice.sourceId }, - { label: "Currency", value: invoice.currency }, - { - label: "Issued", - value: invoice.issuedAt - ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") - : null, - }, - { - label: "Due", - value: invoice.dueAt - ? new Date(invoice.dueAt).toLocaleDateString("en-GB") - : null, - }, - ], + summary, categoryHeader: "Charge type", lines: invoice.lines.map((l) => ({ description: l.description ?? l.chargeType, @@ -458,6 +490,7 @@ export class BillingService { currency: l.currency, })), totals, + qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null, }; } diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts new file mode 100644 index 000000000..00e590e17 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -0,0 +1,46 @@ +import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service"; + +const model = (over: Partial = {}): InvoiceDocumentModel => ({ + kind: "INVOICE", + title: "Freight", + documentNumber: "INV-20260812-00001", + issuedAt: new Date(2026, 7, 12), + status: "PENDING", + currency: "ETB", + summary: [{ label: "Status", value: "PENDING" }], + lines: [], + totals: [{ label: "Total", amount: 100, grand: true }], + ...over, +}); + +describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { + const service = new InvoiceDocumentService({} as never, {} as never); + + it("renders no QR block when qrImageUrl is unset", () => { + const html = service.buildHtml(model()); + expect(html).not.toContain('class="qr"'); + }); + + it("renders the QR image when qrImageUrl is set", () => { + const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" })); + expect(html).toContain('class="qr"'); + expect(html).toContain('src="data:image/png;base64,QR"'); + }); + + it("still shows the IRN text row via the ordinary summary grid", () => { + const html = service.buildHtml( + model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }), + ); + expect(html).toContain("EIMS IRN"); + expect(html).toContain("IRN-123"); + }); + + it("widens the summary's right margin only when a QR is present, to clear the QR block", () => { + // "summary-with-qr" also appears in the always-present