From c7fdb8fd20c100f90d57f9cbfb4273c2971a088e Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Mon, 7 Sep 2026 00:04:31 -0700 Subject: [PATCH] fix(orders): show the real order-rejection reason; fix input a11y and layout Diagnosed "some error occurred" on order placement: verified live (fresh KYC/account-approved, funded test customer) that the exact wire path -- frontend payload construction, CreateOrderRequest schema, Units/Price decimal-string convention -- works correctly end to end (a direct POST with the exact VTI/Buy/2/$20 payload succeeded, 201). The real bug is that frontend/src/utils/apiErrorMessage.js has zero mappings for any order-specific rejection code, so every real, specific reason the backend returns (insufficient_investable_cash, kyc_verification_pending, kyc_verification_rejected, account_approval_pending, account_approval_rejected, customer_not_eligible) collapsed into the generic "Something went wrong" fallback. Reproduced live against an approved-but-unfunded account: before this fix, "Something went wrong"; after, "You don't have enough investable cash for this order." New ordersErrorMessage.js mirrors funding's existing fundingErrorMessage.js pattern; wired into OrderForm, OrderList, and ApprovalBanner (all three previously called the bare utility). Also, per a careful read of every input field: wired validation/server errors into Input's `error` prop (fixes a real accessibility gap -- aria-invalid/aria-describedby never fired before, message rendered through a disconnected sibling paragraph); trimmed the reference-price value before validating and sending (quantity already did this via parseQuantity, reference price did not); added an explicit empty-state message if the securities list is ever empty instead of letting the selected security silently go undefined; added `required` to both selects for parity with the inputs. Layout: OrderForm.css hard-coded max-width: 360px with no breakpoint, and the form wasn't wrapped in a Card -- confirmed visually (a live 1280px snapshot showed the four fields as a narrow login-form-sized column with the rest of the page empty). Replaced with a responsive 2-column field grid (Security+Side, Quantity+Reference price) at 768px+ matching FeesPage.css/FundingPage.css's established breakpoint convention, collapsing to one column below it; wrapped in a Card at the page's --reading-max-width (640px) -- verified both breakpoints live. No backend changes -- the wire contract was already correct. --- .../orders/components/ApprovalBanner.jsx | 4 +- .../features/orders/components/OrderForm.css | 28 +++++++++-- .../features/orders/components/OrderForm.jsx | 49 ++++++++++++------- .../features/orders/components/OrderList.jsx | 4 +- .../src/features/orders/ordersErrorMessage.js | 19 +++++++ frontend/src/pages/OrderNewPage.css | 7 +++ frontend/src/pages/OrderNewPage.jsx | 8 ++- 7 files changed, 92 insertions(+), 27 deletions(-) create mode 100644 frontend/src/features/orders/ordersErrorMessage.js create mode 100644 frontend/src/pages/OrderNewPage.css diff --git a/frontend/src/features/orders/components/ApprovalBanner.jsx b/frontend/src/features/orders/components/ApprovalBanner.jsx index 652b6d1..481c90e 100644 --- a/frontend/src/features/orders/components/ApprovalBanner.jsx +++ b/frontend/src/features/orders/components/ApprovalBanner.jsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Button } from '../../../components/Button' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { getOrdersErrorMessage } from '../ordersErrorMessage.js' import { formatUnitsString } from '../../../utils/format.js' import { useApproveOrder } from '../hooks/useApproveOrder.js' import './ApprovalBanner.css' @@ -26,7 +26,7 @@ export function ApprovalBanner({ order, onApproved }) {

{status === 'error' && (

- {getErrorMessage(error)} + {getOrdersErrorMessage(error)}

)}
diff --git a/frontend/src/features/orders/components/OrderForm.css b/frontend/src/features/orders/components/OrderForm.css index 0dfc5b3..82b1261 100644 --- a/frontend/src/features/orders/components/OrderForm.css +++ b/frontend/src/features/orders/components/OrderForm.css @@ -1,8 +1,25 @@ +/* One column below 768px; Security+Side and Quantity+Reference price pair up into a 2-column + grid at 768px and above, matching FeesPage.css/FundingPage.css's established breakpoint + convention in this codebase (a literal px value, since --breakpoint-md can't be read inside a + media query). The preview, error, and submit button each span the full row regardless of + column count -- pairing a number with its own row of controls reads worse than a clean divider + between input fields and the outcome/action below them. */ .tu-order-form { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: minmax(0, 1fr); gap: var(--space-4); - max-width: 360px; +} + +@media (min-width: 768px) { + .tu-order-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.tu-order-form__preview, +.tu-order-form__error, +.tu-order-form__submit { + grid-column: 1 / -1; } .tu-order-form__preview { @@ -37,3 +54,8 @@ color: var(--color-error); font-size: var(--font-size-caption); } + +.tu-order-form__empty { + grid-column: 1 / -1; + color: var(--color-text-secondary); +} diff --git a/frontend/src/features/orders/components/OrderForm.jsx b/frontend/src/features/orders/components/OrderForm.jsx index e58964d..e5cfb6d 100644 --- a/frontend/src/features/orders/components/OrderForm.jsx +++ b/frontend/src/features/orders/components/OrderForm.jsx @@ -8,8 +8,8 @@ import { Select } from '../../../components/Select' import { Skeleton } from '../../../components/Skeleton' import { useToast } from '../../../components/Toast' import { useSecurities } from '../../portfolio/hooks/useSecurities.js' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { formatMoney } from '../../../utils/format.js' +import { getOrdersErrorMessage } from '../ordersErrorMessage.js' import { usePlaceOrder } from '../hooks/usePlaceOrder.js' import { parseQuantity } from '../parseQuantity.js' import './OrderForm.css' @@ -24,14 +24,19 @@ export function OrderForm() { const [side, setSide] = useState('buy') const [quantity, setQuantity] = useState('') const [referencePrice, setReferencePrice] = useState('') - const [validationMessage, setValidationMessage] = useState(null) + const [quantityError, setQuantityError] = useState(null) + const [referencePriceError, setReferencePriceError] = useState(null) if (securitiesStatus === 'idle' || securitiesStatus === 'loading') { return } if (securitiesStatus === 'error') { - return + return + } + + if (securities.length === 0) { + return

No tradable securities are available right now.

} const selectedSecurity = securities.find((security) => security.security_id === securityId) || securities[0] @@ -50,18 +55,18 @@ export function OrderForm() { const handleSubmit = (event) => { event.preventDefault() - setValidationMessage(null) + setQuantityError(null) + setReferencePriceError(null) + const parsedQuantity = parseQuantity(quantity) if (!parsedQuantity) { - setValidationMessage('Enter a valid quantity greater than zero.') + setQuantityError('Enter a valid quantity greater than zero.') return } - if (!selectedSecurity) { - setValidationMessage('Choose a security.') - return - } - if (!referencePrice || Number.isNaN(Number(referencePrice)) || Number(referencePrice) <= 0) { - setValidationMessage('Enter a valid reference price greater than zero.') + + const trimmedReferencePrice = referencePrice.trim() + if (!trimmedReferencePrice || Number.isNaN(Number(trimmedReferencePrice)) || Number(trimmedReferencePrice) <= 0) { + setReferencePriceError('Enter a valid reference price greater than zero.') return } @@ -69,7 +74,7 @@ export function OrderForm() { securityId: selectedSecurity.security_id, side, quantity: parsedQuantity, - referencePrice, + referencePrice: trimmedReferencePrice, }) .then((order) => { showToast({ message: 'Order placed.', tone: 'success' }) @@ -78,18 +83,24 @@ export function OrderForm() { .catch(() => {}) } - const displayError = validationMessage || (status === 'error' ? getErrorMessage(error) : null) + const submitError = status === 'error' ? getOrdersErrorMessage(error) : null return (
- setSecurityId(event.target.value)} + > {securities.map((security) => ( ))} - setSide(event.target.value)}> @@ -100,6 +111,7 @@ export function OrderForm() { value={quantity} onChange={(event) => setQuantity(event.target.value)} placeholder="0" + error={quantityError} required /> setReferencePrice(event.target.value)} placeholder="0.00" + error={referencePriceError} required /> {notional && ( @@ -117,12 +130,12 @@ export function OrderForm() { {formatMoney(notional)}
)} - {displayError && ( + {submitError && (

- {displayError} + {submitError}

)} - diff --git a/frontend/src/features/orders/components/OrderList.jsx b/frontend/src/features/orders/components/OrderList.jsx index fea97bc..9dbab3d 100644 --- a/frontend/src/features/orders/components/OrderList.jsx +++ b/frontend/src/features/orders/components/OrderList.jsx @@ -4,8 +4,8 @@ import { EmptyState } from '../../../components/EmptyState' import { ErrorState } from '../../../components/ErrorState' import { Skeleton } from '../../../components/Skeleton' import { Table } from '../../../components/Table' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { useOrders } from '../hooks/useOrders.js' +import { getOrdersErrorMessage } from '../ordersErrorMessage.js' import { OrderRow } from './OrderRow.jsx' export function OrderList() { @@ -23,7 +23,7 @@ export function OrderList() { } if (status === 'error') { - return + return } if (orders.length === 0) { diff --git a/frontend/src/features/orders/ordersErrorMessage.js b/frontend/src/features/orders/ordersErrorMessage.js new file mode 100644 index 0000000..1593387 --- /dev/null +++ b/frontend/src/features/orders/ordersErrorMessage.js @@ -0,0 +1,19 @@ +import { getErrorMessage } from '../../utils/apiErrorMessage.js' + +// order_service.py / orders.py error codes -- every real rejection reason a POST /orders or +// POST /orders//approve can return, so a real cause is never swallowed into the generic +// fallback (which is what "some error occurred" looked like before this map existed). +const SPECIFIC_MESSAGES = { + customer_not_eligible: "Your account isn't eligible to place orders yet.", + kyc_verification_pending: 'Your identity verification is still pending — you can place orders once it completes.', + kyc_verification_rejected: 'Your identity verification was not approved. Contact support to resolve this.', + account_approval_pending: 'Your account is still pending approval — you can place orders once it completes.', + account_approval_rejected: 'Your account was not approved. Contact support to resolve this.', + insufficient_investable_cash: "You don't have enough investable cash for this order.", +} + +export function getOrdersErrorMessage(error) { + if (!error) return null + if (SPECIFIC_MESSAGES[error.code]) return SPECIFIC_MESSAGES[error.code] + return getErrorMessage(error) +} diff --git a/frontend/src/pages/OrderNewPage.css b/frontend/src/pages/OrderNewPage.css new file mode 100644 index 0000000..445db5d --- /dev/null +++ b/frontend/src/pages/OrderNewPage.css @@ -0,0 +1,7 @@ +/* The order form is four short fields, not a reading-length document -- the page's own + --reading-max-width (640px) gives it real room for a 2-column field grid (OrderForm.css) + without stretching to the full --content-max-width, which would leave the fields looking lost + in a mostly-empty row on a wide viewport. */ +.tu-page.tu-order-new-page { + max-width: var(--reading-max-width); +} diff --git a/frontend/src/pages/OrderNewPage.jsx b/frontend/src/pages/OrderNewPage.jsx index 1c12adb..8f2860c 100644 --- a/frontend/src/pages/OrderNewPage.jsx +++ b/frontend/src/pages/OrderNewPage.jsx @@ -1,11 +1,15 @@ +import { Card } from '../components/Card' import { OrderForm } from '../features/orders/components/OrderForm.jsx' +import './OrderNewPage.css' import './PageLayout.css' export function OrderNewPage() { return ( -
+

Place an order

- + + +
) }