From 622c690ed119e59269f96a40cb1682ed114dc12a Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 8 Aug 2026 20:58:37 +0000 Subject: [PATCH 1/6] keep cents in CBE bills and prices --- .../src/pages/contracts/ContractDetailPage.tsx | 7 +++---- .../portal/src/pages/contracts/NewShipmentPage.tsx | 10 +++++----- .../src/pages/contracts/new-shipment-form/total.ts | 13 +++++++++++++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 6ceb1fb69..ded69afb1 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -64,6 +64,7 @@ import { } from "@/pages/bookings/booking-display"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; import { formatRateUnit } from "./new-contract-form/unit-rates"; +import { formatAmount } from "./new-shipment-form/total"; import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -719,7 +720,7 @@ export default function ContractDetailPage() { )} - {(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "} + {formatAmount(item.unitPrice)} {pricing.currency}{" "} / {formatRateUnit(item.unit)} @@ -1336,9 +1337,7 @@ export default function ContractDetailPage() { whiteSpace: "nowrap", }} > - {amount > 0 - ? `ETB ${amount.toLocaleString()}` - : "—"} + {amount > 0 ? `ETB ${formatAmount(amount)}` : "—"} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index a5b29a299..347cdd2bb 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -75,7 +75,7 @@ import { createShipmentFormSchema, initialShipmentFormValues, } from "./new-shipment-form/schema"; -import { computeShipmentTotal } from "./new-shipment-form/total"; +import { computeShipmentTotal, formatAmount } from "./new-shipment-form/total"; import { downloadContainerImportTemplate, parseContainerExcel, @@ -1014,7 +1014,7 @@ function PriceConfirmModal({ ))} {overweightSurchargeAmount > 0 - ? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${ + ? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${ validation?.currency ?? total?.currency ?? "" } applies (included in the total below). You can still submit, or go back and adjust weights.` : "An overweight surcharge applies. You can still submit, or go back and adjust weights."} @@ -1038,7 +1038,7 @@ function PriceConfirmModal({ {line.quantity.toLocaleString()} ×{" "} - {line.unitPrice.toLocaleString()} {total.currency} ·{" "} + {formatAmount(line.unitPrice)} {total.currency} ·{" "} {formatRateUnit(line.unit)} @@ -1048,7 +1048,7 @@ function PriceConfirmModal({ c="#10202F" style={{ whiteSpace: "nowrap" }} > - {line.amount.toLocaleString()} {total.currency} + {formatAmount(line.amount)} {total.currency} ))} @@ -1070,7 +1070,7 @@ function PriceConfirmModal({ Total - {total.total.toLocaleString()}{" "} + {formatAmount(total.total)}{" "} {total.currency} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index cbe9ae083..7c80ca1d6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -15,6 +15,19 @@ export interface ShipmentTotal { total: number; } +/** + * Money always prints its cents. Bare toLocaleString() defaults to + * maximumFractionDigits: 0, which rounded the total away from the line items it + * sums (118,171.21 shown as 118,171) — and the customer is billed the exact + * amount, so the shown figure must match to the cent. + */ +export function formatAmount(amount: number | string | null | undefined) { + return Number(amount ?? 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + /** * Quantity a bulk rate bills, in ITS OWN unit. PER_ITEM cargo carries both * figures — the item count prices the booking, the tonnage sizes the wagons — From aedab1b4362bd0d2f2885be370d9b04d880a5130 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 8 Aug 2026 21:09:20 +0000 Subject: [PATCH 2/6] keep cents in CBE bills and prices --- .../src/modules/bookings/booking-pricing.service.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index ba6fece8c..4ebebe879 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -279,9 +279,10 @@ export class BookingPricingService { return { lineItems, - // Grand total is billed in whole currency units — fractional line sums - // (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD. - totalAmount: Math.round(total), + // Grand total keeps its cents, matching the line items it sums — rounding + // to whole birr made the total disagree with the breakdown (135,375.61 of + // lines shown as a 135,376.00 total) and CBE bills this figure to the cent. + totalAmount: round2(total), currency: booking.paymentCurrency, usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, From 182787e143c2d1d723e59da3e16235d1f9817129 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 9 Aug 2026 07:57:45 +0000 Subject: [PATCH 3/6] fix: gm email --- .../companies.fayda-identity.spec.ts | 87 +++++++++++++++++++ .../accounts/companyProfileForm/helpers.ts | 12 ++- .../companyProfileForm/schema.test.ts | 34 ++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index dd198b1c6..599187b22 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -354,6 +354,93 @@ describe("Fayda identity verification binds a person to the company", () => { ).resolves.toBeDefined(); }); + // Fayda's email and phone claims are optional and routinely absent. The owner + // is who the company is reached through and the step renders no input for + // their contact details, so the onboarding account — already OTP-proven — + // stands in rather than leaving the company unreachable. + describe("account contact details stand in for absent Fayda claims", () => { + const noContactClaims = { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", + address: "Addis Ababa", + }; + const account = { + email: "account@example.com", + phoneNumber: "+251911777777", + }; + + it("falls back to the account for an owner Fayda gave no email or phone", async () => { + const { service, ctx } = makeService({ verification: noContactClaims }); + + await service.completeIdentityVerification( + "user-1", + { subject: "owner", code: "c", state: "s" }, + account, + ); + + expect(ctx.attributes.ownerEmail).toBe("account@example.com"); + expect(ctx.attributes.ownerPhone).toBe("+251911777777"); + }); + + it("prefers the Fayda claim over the account when there is one", async () => { + const { service, ctx } = makeService(); + + await service.completeIdentityVerification( + "user-1", + { subject: "owner", code: "c", state: "s" }, + account, + ); + + expect(ctx.attributes.ownerEmail).toBe("haile@example.com"); + expect(ctx.attributes.ownerPhone).toBe("+251922000000"); + }); + + it("leaves the PoA alone — the account is not that person", async () => { + const { service, ctx } = makeService({ verification: noContactClaims }); + + await service.completeIdentityVerification( + "user-1", + { subject: "poa", code: "c", state: "s" }, + account, + ); + + expect(ctx.attributes.poaEmail).toBeUndefined(); + expect(ctx.attributes.poaPhone).toBeUndefined(); + }); + + // Owners verified before the fallback existed hold blank contacts. Copying + // those blanks onto the GM makes generalManagerEmail required by onboarding + // with no field anywhere to satisfy it. + it("fills the GM copy from the account when the stored owner has no contacts", async () => { + const { service, ctx } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await service.setGmSameAsOwner("user-1", account); + + expect(ctx.attributes.gmEmail).toBe("account@example.com"); + expect(ctx.attributes.generalManagerEmail).toBe("account@example.com"); + expect(ctx.attributes.generalManagerPhone).toBe("+251911777777"); + }); + + it("keeps the stored owner contacts when the GM copy has them", async () => { + const { service, ctx } = makeService({ + attributes: { + ...OWNER_VERIFIED, + ownerEmail: "abebe@example.com", + ownerPhone: "+251911000111", + }, + }); + + await service.setGmSameAsOwner("user-1", account); + + expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); + expect(ctx.attributes.generalManagerPhone).toBe("+251911000111"); + }); + }); + it("never locks or gates the general manager — it is not the verified subject", async () => { // GM is a plain typed role; the portal offers a "same as owner" copy, but // the backend must not treat it as identity-owned or require it verified. 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 572627292..36c0072fb 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 @@ -139,10 +139,16 @@ export function stepPayload( }; } case "personnel": + // `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and + // undefined, so an empty string is validated and 400s with + // "generalManagerEmail must be an email". An Ethiopian company never types + // these — the GM comes from the Fayda verification (or the "same as owner" + // declaration), so the form fields are legitimately blank and would fail a + // step that has no input to fix. return { - generalManagerName: d.generalManagerName, - generalManagerEmail: d.generalManagerEmail, - generalManagerPhone: d.generalManagerPhone, + generalManagerName: d.generalManagerName || undefined, + generalManagerEmail: d.generalManagerEmail || undefined, + generalManagerPhone: d.generalManagerPhone || undefined, }; case "contact": return { diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts index eb5471861..6e459a6fb 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts @@ -123,6 +123,40 @@ describe("stepPayload (company)", () => { }); }); +describe("stepPayload (personnel)", () => { + // An Ethiopian company never types the GM — Fayda (or "same as owner") owns + // those fields — so the form holds "". `@IsOptional()` on the DTO skips only + // null/undefined, so an empty string is validated and comes back as + // "generalManagerEmail must be an email", on a step that renders no input. + it("omits blank GM fields instead of sending empty strings", () => { + const payload = stepPayload( + "personnel", + values({ + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + }), + ); + expect(payload.generalManagerName).toBeUndefined(); + expect(payload.generalManagerEmail).toBeUndefined(); + expect(payload.generalManagerPhone).toBeUndefined(); + }); + + it("still sends typed GM details (foreign company)", () => { + const payload = stepPayload( + "personnel", + values({ + generalManagerName: "Abebe Bikila", + generalManagerEmail: "gm@example.com", + generalManagerPhone: "+251911223344", + }), + ); + expect(payload.generalManagerName).toBe("Abebe Bikila"); + expect(payload.generalManagerEmail).toBe("gm@example.com"); + expect(payload.generalManagerPhone).toBe("+251911223344"); + }); +}); + describe("firstPresent", () => { it("skips empty strings rather than stopping at them", () => { expect(firstPresent("", " ", "second@example.com")).toBe( From ff9bb4954aa43b1977d682e3de347ebc0c77cc89 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 9 Aug 2026 13:27:05 +0000 Subject: [PATCH 4/6] fix: the content document --- .../3370000000000-SupportHelpInlineMedia.ts | 91 +++++++ .../dto/support-content.dto.ts | 76 +----- .../support-content.service.spec.ts | 63 ++--- .../support-content.service.ts | 33 +-- .../src/pages/portal_content/AccordionRow.tsx | 95 ------- .../src/pages/portal_content/DocumentRail.tsx | 163 ++++++++++++ .../src/pages/portal_content/EditorPane.tsx | 98 +++++++ .../src/pages/portal_content/FaqEditor.tsx | 242 ------------------ .../src/pages/portal_content/FaqWorkspace.tsx | 227 ++++++++++++++++ .../src/pages/portal_content/HelpEditor.tsx | 125 --------- .../pages/portal_content/LegalDocEditor.tsx | 110 -------- .../src/pages/portal_content/Markdown.tsx | 89 ++++++- .../pages/portal_content/MarkdownEditor.tsx | 194 ++++++++------ .../src/pages/portal_content/MediaDialog.tsx | 230 +++++++++++++++++ .../src/pages/portal_content/MediaManager.tsx | 122 --------- .../portal_content/PortalContentPage.tsx | 119 ++++++++- .../pages/portal_content/SectionWorkspace.tsx | 107 ++++++++ .../pages/portal_content/version-preview.ts | 8 +- .../src/services/portal-content.service.ts | 11 +- .../portal/src/pages/support/DocShell.tsx | 25 +- .../portal/src/pages/support/DocSidebar.tsx | 117 +++++++++ .../portal/src/pages/support/FaqPage.tsx | 24 +- .../portal/src/pages/support/HelpPage.tsx | 115 ++++----- .../portal/src/pages/support/Markdown.tsx | 67 ++++- .../src/pages/support/PrivacyPolicyPage.tsx | 15 +- .../portal/src/pages/support/TermsPage.tsx | 17 +- .../src/pages/support/portal-content.test.ts | 7 +- .../src/pages/support/portal-content.ts | 17 +- .../src/freight/portal-content.defaults.ts | 17 +- packages/types/src/freight/portal-content.ts | 54 ++-- 30 files changed, 1624 insertions(+), 1054 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3370000000000-SupportHelpInlineMedia.ts delete mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/AccordionRow.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/DocumentRail.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/EditorPane.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/FaqEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/FaqWorkspace.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/HelpEditor.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/LegalDocEditor.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/MediaDialog.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/MediaManager.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/SectionWorkspace.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/support/DocSidebar.tsx diff --git a/apps/edr-freight-api/src/migrations/3370000000000-SupportHelpInlineMedia.ts b/apps/edr-freight-api/src/migrations/3370000000000-SupportHelpInlineMedia.ts new file mode 100644 index 000000000..3301056e4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3370000000000-SupportHelpInlineMedia.ts @@ -0,0 +1,91 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Folds each help section's `media[]` array into its markdown body. + * + * Attachments used to hang off the section as a separate list, rendered after + * the text — which meant an author could not put a picture next to the sentence + * it illustrates, and had two different places to manage media. They are now + * embedded with markdown's image syntax, and the renderer picks `` or + * `