From 08df953a35adfff41190fa3bd136160299fdcd88 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sat, 1 Aug 2026 14:22:14 +0300 Subject: [PATCH 01/18] fix: pnpm lock --- pnpm-lock.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6051e12e8..7df61a873 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,10 +98,10 @@ importers: version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz - version: file:local-packages/tria-plc-api-common-1.6.0.tgz(d194f7ef21135288330b07fecd9a2489) + version: file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6) '@tria-plc/iamapi-common': specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz - version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(2079b56e9fb8fa788c1a142167448388) + version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(d0be280d95adfc1b38e59bdc80c5dec5) amqp-connection-manager: specifier: ^5.0.0 version: 5.0.0(amqplib@2.0.1) @@ -16395,7 +16395,7 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65)': + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16406,7 +16406,7 @@ snapshots: '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999) argon2: 0.43.1 axios: 1.17.0 change-case: 5.4.4 @@ -16439,7 +16439,7 @@ snapshots: - debug - supports-color - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz(d194f7ef21135288330b07fecd9a2489)': + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16482,7 +16482,7 @@ snapshots: - debug - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16493,7 +16493,7 @@ snapshots: '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3) api-common: 1.2.2 argon2: 0.43.1 axios: 1.17.0 @@ -16517,7 +16517,7 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(2079b56e9fb8fa788c1a142167448388)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(d0be280d95adfc1b38e59bdc80c5dec5)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16528,7 +16528,7 @@ snapshots: '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(d194f7ef21135288330b07fecd9a2489) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6) argon2: 0.43.1 axios: 1.17.0 class-transformer: 0.5.1 From d524f4465ce8f69fba809286545916f4dbce68eb Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sat, 1 Aug 2026 15:26:11 +0300 Subject: [PATCH 02/18] fix: validation --- .../src/common/validators/is-phone-number.validator.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts index 4f8dac4c7..4f060ca11 100644 --- a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -43,11 +43,17 @@ export function IsValidPhone(validationOptions?: ValidationOptions) { * Normalize a phone string to canonical E.164. Returns the canonical form when * parseable, otherwise the trimmed original (tolerant — never throws), or the * value unchanged when empty/nullish. + * + * Defaults the country to Ethiopia so bare local numbers (no "+", e.g. eTrade's + * "0355235416") resolve the same way the frontend's own toEthiopianE164 already + * assumes — without this hint, libphonenumber can't infer a country for a + * number with no "+" prefix and silently falls through to the untouched local + * string, which then never matches the "+251…" form submitted by the client. */ export function normalizeE164( value: string | null | undefined, ): string | null | undefined { if (value === undefined || value === null || value === '') return value; - const parsed = parsePhoneNumberFromString(value); + const parsed = parsePhoneNumberFromString(value, 'ET'); return parsed?.isValid() ? parsed.number : value.trim(); } From aaf0aa3c92dbe210e931200780c708edc0909bd4 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sun, 2 Aug 2026 00:11:39 +0300 Subject: [PATCH 03/18] fix(verifayda): format nested Fayda address objects into strings --- apps/edr-freight-api/package.json | 3 +- .../3150000000000-FixFaydaAddressShape.ts | 81 +++++++++++++++++++ .../verifayda/utils/client-assertion.util.ts | 5 +- .../modules/verifayda/verifayda.service.ts | 54 ++++++++++++- .../src/modules/verifayda/verifayda.types.ts | 3 +- .../src/components/FaydaVerifyPanel.tsx | 35 +++++++- 6 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index b3ed1c802..d10f19f10 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -36,8 +36,7 @@ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", - "migration:run": "node dist/scripts/migrate.js", + "migration:run": "nest build && node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { diff --git a/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts b/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts new file mode 100644 index 000000000..7a8923f3c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A normalizeUserInfo bug in VerifaydaService read the raw `address#en` / + * `address#am` claim objects (e.g. `{ "zone#en": "...", "region#en": "...", + * "woreda#en": "..." }`) straight through as if they were strings, so any + * company verified before the fix has `attributes.ownerAddress` / + * `poaAddress` stored as that raw object instead of a formatted string — + * which crashes the portal when it tries to render it as text. + * + * Reformats every affected row's ownerAddress/poaAddress into + * "woreda, zone, region" (falling back to whatever #en fields are present, + * in that preferred order, then any leftover fields), mirroring + * VerifaydaService.formatFaydaAddress. Only touches rows where the field is + * still a jsonb object, so it's idempotent and a no-op once repaired. + */ +export class FixFaydaAddressShape3150000000000 implements MigrationInterface { + name = "FixFaydaAddressShape3150000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION pg_temp.format_fayda_address(addr jsonb) + RETURNS text AS $$ + DECLARE + field_order text[] := ARRAY['houseNumber','kebele','woreda','city','subCity','zone','region','postalCode','country']; + f text; + v text; + parts text[] := '{}'; + used_keys text[] := '{}'; + kv record; + BEGIN + IF addr IS NULL OR jsonb_typeof(addr) != 'object' THEN + RETURN NULL; + END IF; + + FOREACH f IN ARRAY field_order LOOP + v := addr ->> (f || '#en'); + IF v IS NOT NULL AND trim(v) != '' THEN + parts := array_append(parts, trim(v)); + used_keys := array_append(used_keys, f || '#en'); + END IF; + END LOOP; + + FOR kv IN SELECT * FROM jsonb_each_text(addr) LOOP + IF kv.key LIKE '%#en' AND NOT (kv.key = ANY(used_keys)) + AND kv.value IS NOT NULL AND trim(kv.value) != '' THEN + parts := array_append(parts, trim(kv.value)); + END IF; + END LOOP; + + IF array_length(parts, 1) IS NULL THEN + RETURN NULL; + END IF; + RETURN array_to_string(parts, ', '); + END; + $$ LANGUAGE plpgsql; + + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, + '{ownerAddress}', + to_jsonb(pg_temp.format_fayda_address(attributes -> 'ownerAddress')) + ) + WHERE jsonb_typeof(attributes -> 'ownerAddress') = 'object'; + + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, + '{poaAddress}', + to_jsonb(pg_temp.format_fayda_address(attributes -> 'poaAddress')) + ) + WHERE jsonb_typeof(attributes -> 'poaAddress') = 'object'; + + DROP FUNCTION pg_temp.format_fayda_address(jsonb); + `); + } + + public async down(): Promise { + // Data repair — not reversible (the original malformed shape isn't worth restoring). + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts index dc3558ccc..d4f50eb62 100644 --- a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -7,12 +7,15 @@ export interface GenerateClientAssertionInput { expiresIn?: string; } +// Mirrors the National ID Program's own reference implementation +// (fayda-auth-python): plain {alg: RS256} header, no kid, no jti — eSignet +// resolves the verification key from client_id alone. export async function generateClientAssertion( input: GenerateClientAssertionInput, ): Promise { const privateKey = await importJWK(input.privateJwk, 'RS256'); return new SignJWT({}) - .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setProtectedHeader({ alg: 'RS256' }) .setIssuer(input.clientId) .setSubject(input.clientId) .setAudience(input.audience) diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index 16441bd0d..af627f25a 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -396,13 +396,63 @@ export class VerifaydaService { ); } + // Like name/gender, address is flattened by eSignet into top-level + // `address#en` / `address#am` keys — but since it's a structured claim, + // each of those is itself an object whose *leaf* fields carry the same + // locale suffix again, e.g. + // `address#en: { "zone#en": "...", "region#en": "...", "woreda#en": "..." }`. + private static readonly ADDRESS_FIELD_ORDER = [ + 'houseNumber', + 'kebele', + 'woreda', + 'city', + 'subCity', + 'zone', + 'region', + 'postalCode', + 'country', + ]; + + private formatFaydaAddress( + address: Record | undefined, + locale: 'en' | 'am', + ): string | undefined { + if (!address) return undefined; + + const formatted = address[`formatted#${locale}`]; + if (typeof formatted === 'string' && formatted.trim()) return formatted; + + const suffix = `#${locale}`; + const byField = new Map(); + for (const [key, value] of Object.entries(address)) { + if (!key.endsWith(suffix) || typeof value !== 'string' || !value.trim()) continue; + byField.set(key.slice(0, -suffix.length), value); + } + + const ordered = VerifaydaService.ADDRESS_FIELD_ORDER.filter((f) => + byField.has(f), + ).map((f) => byField.get(f)!); + const rest = [...byField.entries()] + .filter(([f]) => !VerifaydaService.ADDRESS_FIELD_ORDER.includes(f)) + .map(([, v]) => v); + + const parts = [...ordered, ...rest]; + return parts.length > 0 ? parts.join(', ') : undefined; + } + private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { const nameEn = raw['name#en'] as string | undefined; const nameAm = raw['name#am'] as string | undefined; const genderEn = raw['gender#en'] as string | undefined; const genderAm = raw['gender#am'] as string | undefined; - const addressEn = raw['address#en'] as string | undefined; - const addressAm = raw['address#am'] as string | undefined; + const addressEn = this.formatFaydaAddress( + raw['address#en'] as Record | undefined, + 'en', + ); + const addressAm = this.formatFaydaAddress( + raw['address#am'] as Record | undefined, + 'am', + ); const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; return { diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts index 442a22d2f..03e52d5b0 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts @@ -21,7 +21,8 @@ export interface FaydaUserInfo { gender?: string; birthdate?: string; picture?: string; - address?: Record; + 'address#en'?: Record; + 'address#am'?: Record; [key: string]: unknown; } diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index fc00cfabe..236f8b47a 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -72,6 +72,20 @@ export default function FaydaVerifyPanel({ // panel between steps can't complete a verification against the wrong person. const subjectRef = useRef(subject); subjectRef.current = subject; + // FaydaCallbackPage posts its message from a StrictMode-double-invoked + // effect in dev, so the same one-time-use code+state can arrive twice. + // Track the last state we've started completing so the resend is a no-op. + const handledStateRef = useRef(null); + // Polls the popup so a manually-closed window (no postMessage ever sent) + // still clears `loading` instead of leaving the button spinning forever. + const pollRef = useRef(null); + + const stopPolling = () => { + if (pollRef.current !== null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + }; useEffect(() => { const onMessage = async (event: MessageEvent) => { @@ -79,11 +93,15 @@ export default function FaydaVerifyPanel({ if (event.data?.type !== "fayda-callback") return; if (event.data.error) { + stopPolling(); setLoading(false); setError(event.data.errorDescription ?? event.data.error); return; } if (!event.data.code || !event.data.state) return; + if (handledStateRef.current === event.data.state) return; + handledStateRef.current = event.data.state; + stopPolling(); try { const next = await verifaydaService.completeIdentity( @@ -104,13 +122,17 @@ export default function FaydaVerifyPanel({ } }; window.addEventListener("message", onMessage); - return () => window.removeEventListener("message", onMessage); + return () => { + window.removeEventListener("message", onMessage); + stopPolling(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const startVerification = async () => { setError(null); setLoading(true); + handledStateRef.current = null; try { const authorizationUrl = await verifaydaService.start(); const popup = window.open( @@ -121,8 +143,17 @@ export default function FaydaVerifyPanel({ if (!popup) { setLoading(false); setError("Pop-up blocked — allow pop-ups for this site and try again."); + return; } - // Loading stays on until the popup posts back. + // Loading stays on until the popup posts back — unless the user closes + // it by hand, which never sends a message; poll for that and clear + // loading ourselves so the button doesn't spin forever. + stopPolling(); + pollRef.current = window.setInterval(() => { + if (!popup.closed) return; + stopPolling(); + if (handledStateRef.current === null) setLoading(false); + }, 500); } catch (err) { setLoading(false); setError( From e6ce19ad22aadf2b6e02bd68de72521aefc3b409 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sun, 2 Aug 2026 01:11:04 +0300 Subject: [PATCH 04/18] style: ui fixes --- .../src/components/FaydaVerifyPanel.tsx | 68 +++++++++++-------- .../src/components/onboarding/ETradeInfo.tsx | 58 +++------------- .../src/pages/accounts/CompanyProfileForm.tsx | 10 +-- .../companyProfileForm/ReadOnlyField.tsx | 2 +- .../companyProfileForm/StepSection.tsx | 4 +- 5 files changed, 57 insertions(+), 85 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index 236f8b47a..b07d7352c 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -1,15 +1,23 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { Alert, + Avatar, Badge, Button, Card, Group, - SimpleGrid, Stack, Text, } from "@mantine/core"; -import { BadgeCheck, Clock, ShieldCheck, XCircle } from "lucide-react"; +import { + BadgeCheck, + Clock, + Mail, + MapPin, + Phone, + ShieldCheck, + XCircle, +} from "lucide-react"; import { verifaydaService, @@ -42,10 +50,12 @@ interface FaydaVerifyPanelProps { pendingReview?: boolean; } -function formatDate(iso: string | null): string { - if (!iso) return ""; - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString(); +function getInitials(name: string | null): string { + if (!name) return "?"; + const parts = name.trim().split(/\s+/); + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; + return (first + last).toUpperCase(); } /** @@ -172,7 +182,7 @@ export default function FaydaVerifyPanel({ - {title} identity + {title} {verified ? ( - - - - - - + + + {getInitials(state.name)} + + + + {state.name} + + + } value={state.phone} /> + } value={state.email} /> + + } value={state.address} /> + + )} {error && ( @@ -240,22 +258,16 @@ export default function FaydaVerifyPanel({ ); } -function VerifiedField({ - label, - value, -}: { - label: string; - value: string | null; -}) { +function DataRow({ icon, value }: { icon: ReactNode; value: string | null }) { if (!value) return null; return ( - - - {label} - - + + + {icon} + + {value} - + ); } diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 082331c7b..2ddeb2e2e 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -28,31 +28,6 @@ interface ETradeInfoProps { const isValidTin = (tin: string) => tin.length === 10; -/** Plain-text summary of the fetched eTrade record, downloaded client-side (eTrade returns data, not a document). */ -function downloadTinRecord(tin: string, data: CompanyRegistrationData) { - const lines = [ - `TIN: ${tin}`, - `Company name: ${data.companyName}`, - `Licence number: ${data.licenceNumber}`, - `Status: ${data.statusDescription}`, - `Date registered: ${data.dateRegistered}`, - `Renewed from: ${data.renewedFrom}`, - `Renewal date: ${data.renewalDate}`, - `Renewed to: ${data.renewedTo}`, - `Address: ${[data.region, data.zone, data.woreda, data.kebele, data.houseNo].filter(Boolean).join(", ")}`, - `Manager: ${data.managerName}`, - ]; - const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `tin-${tin}.txt`; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 60_000); -} - export default function ETradeInfo({ tin, register, @@ -86,15 +61,13 @@ export default function ETradeInfo({ }, [tin]); const apiError = - mutation.isError && mutation.error - ? extractApiError(mutation.error) - : null; + mutation.isError && mutation.error ? extractApiError(mutation.error) : null; // A 400 here means eTrade simply has no record for this TIN. const notFound = apiError?.statusCode === 400; const errorMessage = apiError && !notFound ? apiError.message || - "We couldn't reach eTrade to fetch your company information. Please try again." + "We couldn't reach eTrade to fetch your company information. Please try again." : null; const status: ETradeStatus = isLoading @@ -117,18 +90,14 @@ export default function ETradeInfo({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]); - const showRetry = isValidTin(tin) && status !== "verified" && status !== "loading"; + const showRetry = + isValidTin(tin) && status !== "verified" && status !== "loading"; return ( - +
- TIN Number (10 digits){" "} - * - - } + aria-label="TIN Number (10 digits), required" placeholder="0012345678" maxLength={10} error={error} @@ -136,6 +105,7 @@ export default function ETradeInfo({ /> {showRetry && ( )} - {status === "verified" && mutation.data && !mutation.data.tinTaken && ( - - )} - +
{notFound && ( onIdentityChange?.()} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx index 6465375e7..7b8bae2b9 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx @@ -13,7 +13,7 @@ export function ReadOnlyField({ {label} - + {value && value.trim() ? value : "—"}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx index be65e9c73..0b0878c99 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx @@ -1,4 +1,5 @@ import { Badge, Group, Stack, Text } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { Check, X } from "lucide-react"; import type { ReactNode } from "react"; @@ -32,6 +33,7 @@ export default function StepSection({ children: ReactNode; }) { const badge = STATUS_BADGE[status]; + const isMobile = useMediaQuery("(max-width: 48em)"); return ( @@ -75,7 +77,7 @@ export default function StepSection({ )} -
+
{children}
From 7619952b74082f5e77f42458c8d3b148b4b1b3b3 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sun, 2 Aug 2026 01:39:46 +0300 Subject: [PATCH 05/18] fix: etrade loading --- .../src/components/onboarding/ETradeInfo.tsx | 50 ++++++++++++++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 19 +++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 2ddeb2e2e..e92b907e2 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -1,4 +1,4 @@ -import { Alert, Button, Group, Loader, Stack, TextInput } from "@mantine/core"; +import { Alert, Button, Loader, Stack, TextInput } from "@mantine/core"; import { useEffect, useRef } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; import { AlertCircle, Download } from "lucide-react"; @@ -24,6 +24,8 @@ interface ETradeInfoProps { onDataLoaded: (data: CompanyRegistrationData) => void; /** Reports the live lookup status so the parent step can gate on it. */ onStatusChange?: (status: ETradeStatus) => void; + /** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */ + onReset?: () => void; } const isValidTin = (tin: string) => tin.length === 10; @@ -34,14 +36,22 @@ export default function ETradeInfo({ error, onDataLoaded, onStatusChange, + onReset, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; const tinTaken = mutation.data?.tinTaken; + // Bumped on every TIN change so a fetch already in flight for an older TIN + // is ignored when it lands — otherwise a slow lookup can resolve after the + // user has typed a different TIN and overwrite its fields with stale data. + const requestIdRef = useRef(0); + const handleFetch = async () => { if (!isValidTin(tin)) return; + const requestId = ++requestIdRef.current; const result = await mutation.mutateAsync(tin); + if (requestIdRef.current !== requestId) return; if (result && !result.tinTaken) { onDataLoaded(result); } @@ -53,6 +63,16 @@ export default function ETradeInfo({ // doesn't refire the lookup the moment this mounts. const lastFetchedTin = useRef(tin || null); useEffect(() => { + if (tin !== lastFetchedTin.current) { + // TIN moved away from whatever we last fetched — that result (verified + // data, "taken", or an error) no longer describes this TIN. Drop it so + // the UI doesn't keep showing the previous TIN's outcome. + requestIdRef.current++; + if (mutation.data || mutation.error) { + mutation.reset(); + onReset?.(); + } + } if (isValidTin(tin) && lastFetchedTin.current !== tin) { lastFetchedTin.current = tin; handleFetch(); @@ -90,8 +110,13 @@ export default function ETradeInfo({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]); - const showRetry = - isValidTin(tin) && status !== "verified" && status !== "loading"; + // True for the one render between the TIN reaching 10 digits and the + // effect above actually starting the fetch — without this, "Get Data" + // flashes on screen for that frame before `isLoading` ever turns true. + const willAutoFetch = isValidTin(tin) && lastFetchedTin.current !== tin; + const showLoading = isLoading || willAutoFetch; + + const showRetry = isValidTin(tin) && status !== "verified" && !showLoading; return ( @@ -103,18 +128,27 @@ export default function ETradeInfo({ error={error} {...register} /> + {showLoading && ( + + )} {showRetry && ( )}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3800fb2c0..089e88289 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -288,6 +288,24 @@ export default function CompanyProfileForm({ }); }; + // TIN changed since the last successful lookup — the registration/address + // fields it filled in describe the OLD TIN, not this one, so clear them + // rather than leaving them stale on screen. + const handleETradeReset = () => { + setValue("licenceNumber", ""); + setValue("statusDescription", ""); + setValue("dateRegistered", ""); + setValue("renewedFrom", ""); + setValue("renewalDate", ""); + setValue("renewedTo", ""); + setValue("region", ""); + setValue("zone", ""); + setValue("woreda", ""); + setValue("kebele", ""); + setValue("houseNo", ""); + setEtradeOwner(null); + }; + // companyEmail/companyPhone are no longer typed — the Fayda-verified owner // is the highest-trust source (that's the whole point of verifying), eTrade's // registered number and the account email/phone are the fallbacks used @@ -718,6 +736,7 @@ export default function CompanyProfileForm({ error={errors.tinNumber?.message} onDataLoaded={handleETradeDataLoaded} onStatusChange={setTinStatus} + onReset={handleETradeReset} /> {tinVerified && ( Date: Sun, 2 Aug 2026 13:20:25 +0300 Subject: [PATCH 06/18] fix: opt --- .../src/modules/otp/otp.service.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 557548b00..d5688e1c2 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -5,7 +5,7 @@ import { randomInt } from "node:crypto"; import { OtpRepository } from "./otp.repository"; -import { SmsClientService } from "../notifications/sms-client.service"; +import { NotificationsService } from "../notifications/notifications.service"; import { EmailClientService } from "../notifications/email-client.service"; /** @@ -95,7 +95,7 @@ export class OtpService { logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService, + private readonly notifications: NotificationsService, private readonly emailClient: EmailClientService, ) { } @@ -263,17 +263,24 @@ export class OtpService { } } - /** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */ + /** + * SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent + * via NotificationsService's direct-HTTP Ozeking strategy — the same + * transport the notification system uses — rather than the RabbitMQ + * `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the + * request, not just that a broker took ownership of the message. + */ private async dispatchSms( phone: string, otp: string, ): Promise { try { - const { queued } = await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); - return { channel: "sms", queued }; + await this.notifications.directSend( + "sms", + phone, + `Your verification code is ${otp}`, + ); + return { channel: "sms", queued: true }; } catch (error) { return { channel: "sms", From d0cc5c19ee0fcd8f8529e4d9d282723c3ddce645 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 17:52:20 +0000 Subject: [PATCH 07/18] fix --- .../portal/src/pages/accounts/CompanyProfileForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 089e88289..b12fb19eb 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -315,7 +315,7 @@ export default function CompanyProfileForm({ shouldValidate: true, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.email, user.email]); + }, [identity?.owner.email, user.email, rehydrate]); useEffect(() => { setValue( @@ -327,7 +327,7 @@ export default function CompanyProfileForm({ { shouldValidate: true }, ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber]); + }, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber, rehydrate]); // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears From a9e6c0dc6fb626d608484cedc08dcec4bd728cb7 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:02:20 +0000 Subject: [PATCH 08/18] fix: etrade phone verification --- .../portal/src/pages/accounts/CompanyProfileForm.tsx | 10 ++++++++++ .../src/pages/accounts/companyProfileForm/helpers.ts | 3 ++- .../src/pages/accounts/companyProfileForm/schema.ts | 5 +++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index b12fb19eb..0dc02eaa4 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -197,6 +197,7 @@ export default function CompanyProfileForm({ companyEmail: "", companyPhone: "", companyAddress: "", + etradePhone: "", tinNumber: "", vatNumber: "", ownerPassportNumber: "", @@ -279,6 +280,14 @@ export default function CompanyProfileForm({ // setting region/zone/woreda/kebele/houseNo above is enough — no need to // compose it here. companyPhone is derived below (identity → eTrade → // account), not set directly here. + // etradePhone is the raw number eTrade returned for this TIN — kept as its + // own field (distinct from companyPhone, which prefers the Fayda-verified + // owner's phone) so the backend's "matches eTrade's current record" check + // always compares against what eTrade actually said, not the owner's phone. + setValue( + "etradePhone", + data.managerPhone || data.regularPhone || data.mobilePhone, + ); setEtradeOwner({ name: data.managerName, @@ -303,6 +312,7 @@ export default function CompanyProfileForm({ setValue("woreda", ""); setValue("kebele", ""); setValue("houseNo", ""); + setValue("etradePhone", ""); setEtradeOwner(null); }; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index f1a9f4301..a3aad0ee2 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -70,7 +70,7 @@ export function stepPayload( woreda: d.woreda, kebele: d.kebele, houseNo: d.houseNo, - etradePhone: d.companyPhone, + etradePhone: d.etradePhone, }; case "personnel": return { @@ -101,6 +101,7 @@ export function toFormValues(p: ProfileResponse): FormData { companyEmail: p.companyEmail ?? "", companyPhone: p.companyPhone ?? "", companyAddress: p.companyAddress ?? "", + etradePhone: p.etradePhone ?? "", tinNumber: tin, vatNumber: p.vatNumber ?? "", ownerPassportNumber: p.identity?.owner.passportNumber ?? "", diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 2cd06ef8f..e2842dbe6 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -21,6 +21,10 @@ export const onboardingSchema = z.object({ // Derived from the eTrade address parts (kebele/woreda/zone/region); no // standalone input — the granular fields live in the registration section. companyAddress: z.string().optional(), + // Raw phone from the eTrade lookup itself — kept separate from companyPhone + // (which shows the Fayda-verified owner's phone once verified) so the two + // can diverge without the backend's eTrade-authenticity check misfiring. + etradePhone: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), vatNumber: z .string() @@ -124,6 +128,7 @@ export const stepFields: Record = { "companyEmail", "companyPhone", "companyAddress", + "etradePhone", "tinNumber", "vatNumber", "ownerPassportNumber", From ed27170301b8d939a7e5a718f8571f5a39a2fa73 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:07:15 +0000 Subject: [PATCH 09/18] chore: comment out the payment service --- .../src/modules/billing/billing.service.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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 8ede56f69..88ef92b3d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1130,20 +1130,20 @@ export class BillingService { // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the // code — so the demo shortcut must never fire for it. Same for CBE_BILL: its // bill must stay open until CBE actually settles it via /cbe/payment. - if ( - !result.immediateSuccess && - result.response.clientAction?.type !== "COLLECT_OTP" && - opts.method !== "CBE_BILL" - ) { - await this.payment.handlePaymentEvent({ - eventType: "payment.succeeded", - eventId: `demo-${result.intentId}`, - referenceId: invoice.sourceId, - intentId: result.intentId, - providerTxnId: result.providerTxnId, - paidAt: (result.paidAt ?? new Date()).toISOString(), - }); - } + // if ( + // !result.immediateSuccess && + // result.response.clientAction?.type !== "COLLECT_OTP" && + // opts.method !== "CBE_BILL" + // ) { + // await this.payment.handlePaymentEvent({ + // eventType: "payment.succeeded", + // eventId: `demo-${result.intentId}`, + // referenceId: invoice.sourceId, + // intentId: result.intentId, + // providerTxnId: result.providerTxnId, + // paidAt: (result.paidAt ?? new Date()).toISOString(), + // }); + // } if (result.immediateSuccess) { await this.settleByPaymentId( From 7cebef8c396ca1ad0d722183717965a8b5e2c491 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:10:12 +0000 Subject: [PATCH 10/18] fix: min amount of cac --- .../src/modules/payment/payment.service.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 0f628e39c..985b4f53f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -232,10 +232,15 @@ export class PaymentService { referenceType: PaymentReferenceType.SHIPMENT, referenceId: input.referenceId, orderRef: input.orderRef, - // amountMinor: input.amountMinor, // CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was - // debited against the intent amount, so the 1-birr dev shortcut would break it. - amountMinor: isCbeBill ? input.amountMinor : 1, + // debited against the intent amount, so the dev shortcut would break it. + // CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev + // shortcut floor is 10, not 1. + amountMinor: isCbeBill + ? input.amountMinor + : input.method === ProviderMethod.CAC_BANK + ? 10 + : 1, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, From 7762d6c2d300b097c6a6e73f92aa24c5bd3c195d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:28:40 +0000 Subject: [PATCH 11/18] fix: payemetn race condition --- .../modules/payment/payment.service.spec.ts | 42 +++++++++++++++++++ .../src/modules/payment/payment.service.ts | 14 ++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts index ed0f8da58..aca224c07 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -188,3 +188,45 @@ describe("PaymentClientService.confirmOtp", () => { ); }); }); + +describe("PaymentService.markIntentSucceeded", () => { + const build = (rows: Record[]) => { + const repo = makeRepo(rows); + const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) }; + const service = new PaymentService( + repo as never, + {} as never, + billing as never, + ); + return { service, repo, billing }; + }; + + it("re-notifies billing on an already-success intent so a settle that died mid-way converges on redelivery", async () => { + const paidAt = new Date("2026-08-01T09:00:00.000Z"); + const { service, repo, billing } = build([ + localIntent({ status: "success", transactionId: "txn-1", paidAt }), + ]); + + const result = await service.markIntentSucceeded("intent-1", { + notify: true, + }); + + expect(result.alreadyFinalized).toBe(true); + // No re-write of the intent row… + expect(repo.update).not.toHaveBeenCalled(); + // …but billing still gets the (idempotent) settle call. + expect(billing.settleByPaymentId).toHaveBeenCalledWith( + "intent-1", + "txn-1", + paidAt, + ); + }); + + it("does not notify billing when notify is false, even when already success", async () => { + const { service, billing } = build([localIntent({ status: "success" })]); + + await service.markIntentSucceeded("intent-1", { notify: false }); + + expect(billing.settleByPaymentId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 985b4f53f..669c7c27b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -451,7 +451,19 @@ export class PaymentService { ): Promise<{ alreadyFinalized: boolean }> { const intent = await this.paymentRepo.findOneBy({ id: intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; + if (intent.status === "success") { + // Still notify billing: a prior delivery may have flipped the intent to + // success and then died before the invoice settled (the two steps are not + // atomic). settleByPaymentId is idempotent — no open invoice, no-op. + if (opts.notify !== false) { + await this.billing.settleByPaymentId( + intent.id, + opts.providerTxnId ?? intent.transactionId ?? undefined, + opts.paidAt ?? intent.paidAt ?? undefined, + ); + } + return { alreadyFinalized: true }; + } const paidAt = opts.paidAt ?? new Date(); await this.paymentRepo.update( From 3a3c309a5b450459d20e388415d5419b5b1efc9e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:33:38 +0000 Subject: [PATCH 12/18] fix: etrade inconsistencies --- .../modules/companies/companies.service.ts | 36 ++++++++----------- .../companies/dto/update-profile.dto.ts | 5 ++- .../companies/services/etrade.service.ts | 4 ++- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index d9798af5d..e0af07e0b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -928,7 +928,7 @@ export class CompaniesService { ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); - await this.assertEtradeFieldsAuthentic(company, dto); + await this.applyEtradeSourcedFields(company, dto); // Naming (or renaming) a Power of Attorney is one of the writes that can // leave the company with a representative and nothing evidencing them, so @@ -3216,22 +3216,24 @@ export class CompaniesService { /** * An eTrade-sourced field can only ever hold what a fresh eTrade lookup for * this TIN actually returns — the portal never lets the customer type these - * once eTrade has supplied them, so a mismatch here means either stale - * client state or a hand-crafted request, and either way the write is - * refused rather than silently trusting it. + * once eTrade has supplied them. Rather than trust the client's copy (stale + * cache, hand-crafted request, or just a formatting mismatch) and reject it, + * refetch eTrade ourselves and overwrite the touched fields with whatever it + * says now — the client's submitted values for these keys only matter as a + * "this field is part of the save" flag, never as data we persist. */ - private async assertEtradeFieldsAuthentic( + private async applyEtradeSourcedFields( company: Company, dto: UpdateProfileDto, ): Promise { const touched = ETRADE_SOURCED_FIELDS.some( - (key) => dto[key] !== undefined, + (key) => key !== "tin" && dto[key] !== undefined, ); if (!touched) return; const tin = dto.tin ?? company.tin; const registration = await this.resolveEtradeRegistration(tin); - const expected: Partial> = { + const fresh: Partial> = { companyName: registration.companyName, licenceNumber: registration.licenceNumber, statusDescription: registration.statusDescription, @@ -3251,21 +3253,11 @@ export class CompaniesService { }; for (const key of ETRADE_SOURCED_FIELDS) { - const submitted = dto[key]; - if (submitted === undefined) continue; - const source = expected[key]; - // eTrade left this field blank — the onboarding/settings card falls back - // to letting the customer type it directly, so nothing to check against. - if (!source) continue; - const same = - key === "etradePhone" - ? normalizeE164(String(submitted)) === normalizeE164(source) - : submitted === source; - if (!same) { - throw new BadRequestException( - `${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`, - ); - } + if (key === "tin" || dto[key] === undefined) continue; + const value = fresh[key]; + // eTrade left this field blank — fall back to whatever the client sent + // (the onboarding/settings card lets the customer type it directly then). + if (value) (dto as Record)[key] = value; } } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 9f7d1ed39..596644fab 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -179,9 +179,12 @@ export class UpdateProfileDto { @MaxLength(100) houseNo?: string; + // Not validated as a phone number: eTrade-sourced, so a fresh eTrade lookup + // overwrites whatever the client sends here — see + // CompaniesService.applyEtradeSourcedFields. Presence just flags "this save + // touches an eTrade-owned field." @IsOptional() @IsString() @MaxLength(20) - @IsValidPhone() etradePhone?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 51bdb2df6..3292ee2d3 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -108,7 +108,9 @@ export class ETradeService { dateRegistered: businessInfo.DateRegistered, renewedFrom: businessInfo.RenewedFrom, renewalDate: businessInfo.RenewalDate, - renewedTo: businessInfo.RenewedTo, + // RenewedTo is ISO ("2018-07-07T00:00:00"); RenewedToDateString matches + // RenewedFrom/RenewalDate's "M/D/YYYY" format — use that for consistency. + renewedTo: businessInfo.RenewedToDateString, // eTrade returns uncoded uppercase text and sometimes a zone name in the // Region slot. Map it onto the canonical list; an unresolved value yields // "" so the form asks the user to pick rather than failing validation on From dd0c7af2516efff5416959ca4a4507eb2589d86a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:34:12 +0000 Subject: [PATCH 13/18] fix: internal payment controller --- .../src/modules/payment/internal-payment.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index b5ff51c48..94c1268f6 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -19,6 +19,7 @@ import { import { PaymentService } from "./payment.service"; import { BillingService } from "../billing/billing.service"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { Public } from "@edr/api-common"; /** * Consumer side of the payment microservice's outbox relay. Only the payment service may @@ -28,6 +29,9 @@ import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") +// Skips the global JwtGuard (no end-user JWT on a service-to-service call); +// ServiceAuthGuard below still enforces the shared SERVICE_AUTH_TOKEN. +@Public() @UseGuards(ServiceAuthGuard) @Controller("internal/payments") export class InternalPaymentController { From ae037fd29d542f50bb2ad94222a5c90a0e3d136b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:42:38 +0000 Subject: [PATCH 14/18] fix: padding --- .../src/pages/contracts/ContractViewPage.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index c525b03c4..8f13e1466 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -33,8 +33,7 @@ import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; import { extractApiError } from "@/utils/result"; -const CONSENT_TEXT = - "I have read the entire contract and agree to its terms."; +const CONSENT_TEXT = "I have read the entire contract and agree to its terms."; /** * Customer contract preview + sign. Customers must scroll through the full @@ -224,7 +223,10 @@ export default function ContractViewPage() { } return ( - +