From 02c734bcd60b8b424d012c170714c90a58d69746 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Tue, 8 Sep 2026 20:06:08 -0700 Subject: [PATCH 01/14] feat(frontend): add Vitest harness, extended type tokens, and theme toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the foundation later frontend tasks build on: test infra (vitest + testing-library, jsdom, one trivial passing test) so every future component can ship with tests as CLAUDE.md requires; three additive type-scale tokens (display-xl, title, section) for the upcoming 5-destination redesign; and an explicit light/dark/system theme toggle (design-system.md §2.2) with its context, persistence, and three new icon glyphs. Nothing here is mounted in the app shell yet -- that's a later task. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HV5c66D3RU7zTqGjdtBnFz --- frontend/package-lock.json | 1284 ++++++++++++++++- frontend/package.json | 12 +- frontend/src/components/Icon/paths.js | 9 + .../components/ThemeToggle/ThemeToggle.css | 17 + .../components/ThemeToggle/ThemeToggle.jsx | 23 + .../ThemeToggle/ThemeToggle.test.jsx | 68 + frontend/src/components/ThemeToggle/index.js | 1 + frontend/src/contexts/ThemeContext.jsx | 55 + frontend/src/styles/tokens.css | 12 + frontend/src/test/setup.js | 18 + frontend/src/utils/format.test.js | 12 + frontend/vite.config.js | 5 + 12 files changed, 1505 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/ThemeToggle/ThemeToggle.css create mode 100644 frontend/src/components/ThemeToggle/ThemeToggle.jsx create mode 100644 frontend/src/components/ThemeToggle/ThemeToggle.test.jsx create mode 100644 frontend/src/components/ThemeToggle/index.js create mode 100644 frontend/src/contexts/ThemeContext.jsx create mode 100644 frontend/src/test/setup.js create mode 100644 frontend/src/utils/format.test.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0edee83..8bb75a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,11 +16,342 @@ "react-router-dom": "^7.18.3" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.1.0", + "@vitest/coverage-v8": "^5.0.0", + "jsdom": "^30.0.1", "oxlint": "^1.79.0", - "vite": "^8.2.2" + "vite": "^8.2.2", + "vitest": "^5.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@oxc-project/types": { @@ -669,6 +1000,138 @@ "node": ">=12.16" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -719,19 +1182,214 @@ } } }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "node_modules/@vitest/coverage-v8": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", + "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^1.0.0", + "@vitest/istanbul-lib-report": "^1.0.0", + "ast-v8-to-istanbul": "^1.0.5", + "magicast": "^0.5.4", + "obug": "^2.1.4", + "std-env": "^4.2.0", + "tinyrainbow": "^3.1.1" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "5.0.0", + "vitest": "5.0.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", + "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz", + "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/istanbul-lib-coverage": "1.0.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.6.tgz", + "integrity": "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -739,12 +1397,51 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -755,6 +1452,54 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -788,12 +1533,83 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1079,6 +1895,66 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1107,6 +1983,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.81.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", @@ -1156,6 +2046,19 @@ } } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1205,6 +2108,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -1216,6 +2143,16 @@ "react-is": "^16.13.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1294,6 +2231,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", @@ -1328,6 +2289,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.7" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1340,6 +2314,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1350,6 +2331,60 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1367,6 +2402,72 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", @@ -1444,6 +2545,171 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 2a3b4a1..7e054d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "oxlint", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@stripe/stripe-js": "^9.15.0", @@ -18,10 +20,16 @@ "react-router-dom": "^7.18.3" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.1.0", + "@vitest/coverage-v8": "^5.0.0", + "jsdom": "^30.0.1", "oxlint": "^1.79.0", - "vite": "^8.2.2" + "vite": "^8.2.2", + "vitest": "^5.0.0" } } diff --git a/frontend/src/components/Icon/paths.js b/frontend/src/components/Icon/paths.js index e41bde0..d78e811 100644 --- a/frontend/src/components/Icon/paths.js +++ b/frontend/src/components/Icon/paths.js @@ -40,4 +40,13 @@ export const ICON_PATHS = { // (Icon.css: fill:none by default), matching the system's established outline weight. stop: ['M4.5 4.5h7v7h-7z'], history: ['M8 4.5v4l2.8 1.6', 'M2.7 8a5.3 5.3 0 1 0 1.6-3.8', 'M2.4 3.6v2.2h2.2'], + sun: [ + 'M8 8m-2.5 0a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0', + 'M8 2v2', + 'M8 12v2', + 'M12 8h2', + 'M2 8h2', + ], + moon: ['M14 8.5A6 6 0 1 1 7.5 2A4.7 4.7 0 0 0 14 8.5z'], + monitor: ['M2.5 3.5h11v8h-11z', 'M8 11.5v2', 'M5.5 13.5h5'], } diff --git a/frontend/src/components/ThemeToggle/ThemeToggle.css b/frontend/src/components/ThemeToggle/ThemeToggle.css new file mode 100644 index 0000000..bcbc1ca --- /dev/null +++ b/frontend/src/components/ThemeToggle/ThemeToggle.css @@ -0,0 +1,17 @@ +.tu-theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + cursor: pointer; +} + +.tu-theme-toggle:hover { + background: var(--color-surface-sunken); + color: var(--color-text); +} diff --git a/frontend/src/components/ThemeToggle/ThemeToggle.jsx b/frontend/src/components/ThemeToggle/ThemeToggle.jsx new file mode 100644 index 0000000..cdab3b7 --- /dev/null +++ b/frontend/src/components/ThemeToggle/ThemeToggle.jsx @@ -0,0 +1,23 @@ +import { Icon } from '../Icon/Icon.jsx' +import { useTheme } from '../../contexts/ThemeContext.jsx' +import './ThemeToggle.css' + +const NEXT_THEME = { light: 'dark', dark: 'system', system: 'light' } +const ICON_BY_THEME = { light: 'sun', dark: 'moon', system: 'monitor' } + +/** Cycles light -> dark -> system -> light (design-system.md §2.2's explicit in-app toggle). */ +export function ThemeToggle() { + const { theme, setTheme } = useTheme() + const next = NEXT_THEME[theme] + + return ( + + ) +} diff --git a/frontend/src/components/ThemeToggle/ThemeToggle.test.jsx b/frontend/src/components/ThemeToggle/ThemeToggle.test.jsx new file mode 100644 index 0000000..ecafe9f --- /dev/null +++ b/frontend/src/components/ThemeToggle/ThemeToggle.test.jsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ThemeProvider } from '../../contexts/ThemeContext.jsx' +import { ThemeToggle } from './ThemeToggle.jsx' + +function renderToggle() { + return render( + + + , + ) +} + +afterEach(() => { + localStorage.clear() + document.documentElement.removeAttribute('data-theme') + vi.restoreAllMocks() +}) + +describe('ThemeToggle', () => { + it('renders, defaulting to system with the next state as its accessible name', () => { + renderToggle() + expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument() + }) + + it('cycles light -> dark -> system on click, applying data-theme and persisting each step', async () => { + const user = userEvent.setup() + renderToggle() + const button = screen.getByRole('button') + + await user.click(button) + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + expect(localStorage.getItem('trueup.theme.v1')).toBe('light') + expect(button).toHaveAccessibleName('Switch to dark theme') + + await user.click(button) + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + expect(localStorage.getItem('trueup.theme.v1')).toBe('dark') + expect(button).toHaveAccessibleName('Switch to system theme') + + await user.click(button) + expect(document.documentElement.hasAttribute('data-theme')).toBe(false) + expect(localStorage.getItem('trueup.theme.v1')).toBe('system') + expect(button).toHaveAccessibleName('Switch to light theme') + }) + + it('falls back cleanly when localStorage.setItem throws (private mode)', async () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage disabled') + }) + const user = userEvent.setup() + renderToggle() + const button = screen.getByRole('button') + + await user.click(button) + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + expect(button).toHaveAccessibleName('Switch to dark theme') + }) + + it('falls back to system when localStorage.getItem throws on mount', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage disabled') + }) + renderToggle() + expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/ThemeToggle/index.js b/frontend/src/components/ThemeToggle/index.js new file mode 100644 index 0000000..9cd6d3c --- /dev/null +++ b/frontend/src/components/ThemeToggle/index.js @@ -0,0 +1 @@ +export { ThemeToggle } from './ThemeToggle.jsx' diff --git a/frontend/src/contexts/ThemeContext.jsx b/frontend/src/contexts/ThemeContext.jsx new file mode 100644 index 0000000..61a3f97 --- /dev/null +++ b/frontend/src/contexts/ThemeContext.jsx @@ -0,0 +1,55 @@ +import { createContext, useContext, useEffect, useState } from 'react' + +const ThemeContext = createContext(undefined) + +const STORAGE_KEY = 'trueup.theme.v1' +const VALID_THEMES = ['light', 'dark', 'system'] + +function readStoredTheme() { + try { + const stored = localStorage.getItem(STORAGE_KEY) + return VALID_THEMES.includes(stored) ? stored : 'system' + } catch { + return 'system' + } +} + +function applyTheme(theme) { + if (theme === 'system') { + document.documentElement.removeAttribute('data-theme') + } else { + document.documentElement.setAttribute('data-theme', theme) + } +} + +/** + * Explicit in-app theme toggle, persisted per browser (design-system.md §2.2). `'system'` defers to + * the OS `prefers-color-scheme` media query already wired into tokens.css; `'light'`/`'dark'` pin it + * via `data-theme`, which tokens.css's explicit-toggle block reads. + */ +export function ThemeProvider({ children }) { + const [theme, setThemeState] = useState(readStoredTheme) + + useEffect(() => { + applyTheme(theme) + }, [theme]) + + const setTheme = (next) => { + setThemeState(next) + try { + localStorage.setItem(STORAGE_KEY, next) + } catch { + // Private mode or storage-disabled browser -- theme still applies for this session. + } + } + + return {children} +} + +export function useTheme() { + const context = useContext(ThemeContext) + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider') + } + return context +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 26cb88f..6592518 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -71,6 +71,18 @@ --line-height-caption: 18px; --font-weight-caption: 400; + --font-size-display-xl: 56px; + --line-height-display-xl: 60px; + --font-weight-display-xl: 600; + + --font-size-title: 28px; + --line-height-title: 34px; + --font-weight-title: 600; + + --font-size-section: 20px; + --line-height-section: 28px; + --font-weight-section: 600; + /* Spacing scale (§4) */ --space-1: 4px; --space-2: 8px; diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js new file mode 100644 index 0000000..a91263c --- /dev/null +++ b/frontend/src/test/setup.js @@ -0,0 +1,18 @@ +import '@testing-library/jest-dom' + +// jsdom has no real implementation of matchMedia -- needed by anything reading +// prefers-color-scheme or prefers-reduced-motion. +window.matchMedia = + window.matchMedia || + function matchMedia(query) { + return { + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + } + } diff --git a/frontend/src/utils/format.test.js b/frontend/src/utils/format.test.js new file mode 100644 index 0000000..f128984 --- /dev/null +++ b/frontend/src/utils/format.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { formatMoney } from './format.js' + +describe('formatMoney', () => { + it('masks a positive value with thousands separators and 2 decimals', () => { + expect(formatMoney('12480.06')).toBe('$12,480.06') + }) + + it('masks a negative value with a leading minus outside the dollar sign', () => { + expect(formatMoney('-1234.5')).toBe('-$1,234.50') + }) +}) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8207e71..54cea99 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -16,4 +16,9 @@ export default defineConfig({ }, }, }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.js'], + globals: true, + }, }) From 87cb209e0f619849ba6290713df015617a9c82c7 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Tue, 8 Sep 2026 20:23:40 -0700 Subject: [PATCH 02/14] feat(marketdata): add GET /api/v1/securities watchlist read endpoint Adds a read-only security catalogue endpoint for a future watchlist feature: SecurityRepository.list_active (status-filtered, keyset-paginated) and DailyCloseRepository.latest_for_securities (one batched query, ordered market_date before recorded_at so a late correction never outranks a genuinely newer close), a SecurityCatalogService, and the controller/view pair following the profile.py four-file pattern. Any authenticated session (customer or staff) may read it, since the catalogue is not tenant-scoped. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HV5c66D3RU7zTqGjdtBnFz --- backend/app/__init__.py | 2 + backend/app/controllers/api/securities.py | 94 +++++++ backend/app/models/marketdata/daily_close.py | 26 +- backend/app/models/marketdata/security.py | 16 +- backend/app/services/marketdata/__init__.py | 0 .../marketdata/security_catalog_service.py | 51 ++++ backend/app/views/securities.py | 39 +++ backend/tests/api/test_securities.py | 263 ++++++++++++++++++ .../test_security_catalog_repositories.py | 191 +++++++++++++ 9 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 backend/app/controllers/api/securities.py create mode 100644 backend/app/services/marketdata/__init__.py create mode 100644 backend/app/services/marketdata/security_catalog_service.py create mode 100644 backend/app/views/securities.py create mode 100644 backend/tests/api/test_securities.py create mode 100644 backend/tests/integration/test_security_catalog_repositories.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 3818f05..d55fd88 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -24,6 +24,7 @@ from app.controllers.api.orders import orders_bp from app.controllers.api.portfolios import portfolios_bp from app.controllers.api.profile import profile_bp +from app.controllers.api.securities import securities_bp from app.controllers.api.statements import statements_bp from app.controllers.api.valuation import valuation_bp from app.controllers.health import health_bp @@ -105,6 +106,7 @@ def create_app(settings: Settings | None = None) -> Flask: app.register_blueprint(chat_bp) app.register_blueprint(portfolios_bp) app.register_blueprint(profile_bp) + app.register_blueprint(securities_bp) app.register_blueprint(admin_rebalance_bp) app.register_blueprint(admin_customers_bp) app.register_blueprint(admin_kyc_overrides_bp) diff --git a/backend/app/controllers/api/securities.py b/backend/app/controllers/api/securities.py new file mode 100644 index 0000000..f57cd6f --- /dev/null +++ b/backend/app/controllers/api/securities.py @@ -0,0 +1,94 @@ +"""Security catalogue routes: `GET /api/v1/securities` backs a future watchlist feature (a later, +unrelated frontend task). Not tenant-scoped -- any authenticated session (customer or staff) reads +the same active-security list, so there is no ownership check beyond authentication. +""" + +from __future__ import annotations + +import uuid +from typing import Any + +from flask import Blueprint, jsonify, request +from flask import session as flask_session +from flask_login import current_user + +from app.core.errors import UnauthenticatedError, ValidationError +from app.core.pagination import normalize_limit +from app.core.uow import SessionRole +from app.extensions import limiter +from app.models.marketdata import MarketDataUnitOfWork +from app.services.marketdata.security_catalog_service import SecurityCatalogService +from app.views.securities import ( + DailyCloseSummaryResponse, + SecuritiesListResponse, + SecurityResponse, +) + +securities_bp = Blueprint("securities", __name__, url_prefix="/api/v1/securities") + + +def _session_role() -> SessionRole: + if not current_user.is_authenticated: + raise UnauthenticatedError("Authentication required") + return SessionRole(current_user.role) + + +def _uow_customer_id(role: SessionRole) -> uuid.UUID | None: + """`UnitOfWork` requires a non-null `customer_id` for a customer-role session even though this + catalogue is not tenant-scoped (`app/core/uow.py`'s constructor invariant) -- resolved from the + session exactly as `app/controllers/api/lots.py`'s `_uow_customer_id` does; a staff session + passes `None`.""" + if role is not SessionRole.CUSTOMER: + return None + raw_user_id = flask_session.get("_user_id") + if not raw_user_id: + raise UnauthenticatedError("No authenticated session") + return uuid.UUID(raw_user_id) + + +def _parse_optional_limit(raw: str | None) -> int | None: + if raw is None: + return None + try: + return int(raw) + except ValueError as exc: + raise ValidationError("limit must be an integer") from exc + + +@securities_bp.route("", methods=["GET"]) +@limiter.limit("60 per minute") +def list_securities() -> Any: + role = _session_role() + limit = normalize_limit(_parse_optional_limit(request.args.get("limit"))) + after = request.args.get("after") + + with MarketDataUnitOfWork(customer_id=_uow_customer_id(role), role=role) as uow: + rows, next_cursor = SecurityCatalogService(uow).list_active(limit=limit, after=after) + + view = SecuritiesListResponse( + securities=[ + SecurityResponse( + security_id=security.id, + symbol=security.symbol, + name=security.name, + asset_class=security.asset_class.value, + last_close=( + DailyCloseSummaryResponse( + price=daily_close.close_price, + market_date=daily_close.market_date, + source=daily_close.source.value, + status=daily_close.status.value, + ) + if daily_close is not None + else None + ), + ) + for security, daily_close in rows + ], + next_cursor=next_cursor, + ) + + return jsonify(view.model_dump(mode="json")), 200 + + +__all__ = ["securities_bp"] diff --git a/backend/app/models/marketdata/daily_close.py b/backend/app/models/marketdata/daily_close.py index 2049795..a63c7d6 100644 --- a/backend/app/models/marketdata/daily_close.py +++ b/backend/app/models/marketdata/daily_close.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING from sqlalchemy import Date as SQLAlchemyDate -from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func +from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func, select from sqlalchemy import Enum as SQLAlchemyEnum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -26,6 +26,8 @@ from app.models.ledger._enum import enum_values if TYPE_CHECKING: + from collections.abc import Sequence + from app.core.uow import UnitOfWork @@ -102,3 +104,25 @@ def latest(self, *, security_id: uuid.UUID, market_date: date) -> DailyClose | N .order_by(DailyClose.recorded_at.desc()) .first() ) + + def latest_for_securities( + self, security_ids: Sequence[uuid.UUID] + ) -> dict[uuid.UUID, DailyClose]: + """Batched latest close per security (GET /api/v1/securities) -- one query, not a loop. + Ordered `market_date DESC` before `recorded_at DESC`: a late correction to an old date + must never be picked over a genuinely newer close (`DISTINCT ON` keeps the first row of + each `security_id` group under this ordering).""" + if not security_ids: + return {} + statement = ( + select(DailyClose) + .where(DailyClose.security_id.in_(security_ids)) + .distinct(DailyClose.security_id) + .order_by( + DailyClose.security_id, + DailyClose.market_date.desc(), + DailyClose.recorded_at.desc(), + ) + ) + rows = self.session.execute(statement).scalars().all() + return {row.security_id: row for row in rows} diff --git a/backend/app/models/marketdata/security.py b/backend/app/models/marketdata/security.py index af08c5c..b616490 100644 --- a/backend/app/models/marketdata/security.py +++ b/backend/app/models/marketdata/security.py @@ -13,7 +13,7 @@ from enum import StrEnum from typing import TYPE_CHECKING -from sqlalchemy import DateTime, String, func, select +from sqlalchemy import DateTime, String, func, select, tuple_ from sqlalchemy import Enum as SQLAlchemyEnum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -79,3 +79,17 @@ def list_by_ids(self, security_ids: Sequence[uuid.UUID]) -> list[Security]: return [] statement = select(Security).where(Security.id.in_(security_ids)) return list(self.session.execute(statement).scalars().all()) + + def list_active( + self, *, limit: int, after: tuple[str, uuid.UUID] | None + ) -> list[Security]: + """Keyset-paginated on `(symbol, id)` (GET /api/v1/securities); fetches `limit + 1` rows so + the service layer can tell whether a further page exists without a separate `COUNT`.""" + statement = select(Security).where(Security.status == SecurityStatus.ACTIVE) + if after is not None: + after_symbol, after_id = after + statement = statement.where( + tuple_(Security.symbol, Security.id) > (after_symbol, after_id) + ) + statement = statement.order_by(Security.symbol.asc(), Security.id.asc()).limit(limit + 1) + return list(self.session.execute(statement).scalars().all()) diff --git a/backend/app/services/marketdata/__init__.py b/backend/app/services/marketdata/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/marketdata/security_catalog_service.py b/backend/app/services/marketdata/security_catalog_service.py new file mode 100644 index 0000000..d161cb8 --- /dev/null +++ b/backend/app/services/marketdata/security_catalog_service.py @@ -0,0 +1,51 @@ +"""Read-only security catalogue backing `GET /api/v1/securities` (a future watchlist feature). +Not tenant-scoped -- any authenticated session sees the same active-security list. +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from app.core.errors import ValidationError +from app.core.pagination import decode_cursor, paginate + +if TYPE_CHECKING: + from app.models.marketdata import MarketDataUnitOfWork + from app.models.marketdata.daily_close import DailyClose + from app.models.marketdata.security import Security + + +def _decode_security_cursor(cursor: str) -> tuple[str, uuid.UUID]: + decoded = decode_cursor(cursor) + if len(decoded) != 2 or not isinstance(decoded[0], str) or not isinstance(decoded[1], str): + raise ValidationError("invalid pagination cursor") + try: + return decoded[0], uuid.UUID(decoded[1]) + except ValueError as exc: + raise ValidationError("invalid pagination cursor") from exc + + +class SecurityCatalogService: + """Lists active securities with each one's latest close, keyset-paginated on `(symbol, id)`.""" + + def __init__(self, uow: MarketDataUnitOfWork) -> None: + self._uow = uow + + def list_active( + self, *, limit: int, after: str | None + ) -> tuple[list[tuple[Security, DailyClose | None]], str | None]: + cursor = _decode_security_cursor(after) if after is not None else None + rows = self._uow.securities.list_active(limit=limit, after=cursor) + page = paginate( + rows, limit=limit, cursor_key=lambda security: (security.symbol, str(security.id)) + ) + + closes = self._uow.daily_closes.latest_for_securities( + [security.id for security in page.items] + ) + paired = [(security, closes.get(security.id)) for security in page.items] + return paired, page.next_cursor + + +__all__ = ["SecurityCatalogService"] diff --git a/backend/app/views/securities.py b/backend/app/views/securities.py new file mode 100644 index 0000000..fbe7bcd --- /dev/null +++ b/backend/app/views/securities.py @@ -0,0 +1,39 @@ +"""Response schemas for `app/controllers/api/securities.py` -- the security catalogue backing a +future watchlist feature (unrelated frontend task). Explicit allow-list only, never a raw +`Security`/`DailyClose` entity -- `app/views/` may not import `app/models/`, enforced by +`lint-imports`'s `views-are-not-entities` contract. `DailyCloseSummaryResponse.source` is passed +through unmodified from `DailyClose.source` -- the exact provenance value `SimulatedBadge` renders, +never re-derived or hardcoded here. +""" + +from __future__ import annotations + +import uuid # noqa: TC003 -- Pydantic resolves field annotations eagerly. +from datetime import date # noqa: TC003 -- Pydantic resolves field annotations eagerly. + +from pydantic import BaseModel + +from app.core.money import Price # noqa: TC001 -- Pydantic resolves field annotations eagerly. + + +class DailyCloseSummaryResponse(BaseModel): + price: Price + market_date: date + source: str + status: str + + +class SecurityResponse(BaseModel): + security_id: uuid.UUID + symbol: str + name: str + asset_class: str + last_close: DailyCloseSummaryResponse | None + + +class SecuritiesListResponse(BaseModel): + securities: list[SecurityResponse] + next_cursor: str | None + + +__all__ = ["DailyCloseSummaryResponse", "SecuritiesListResponse", "SecurityResponse"] diff --git a/backend/tests/api/test_securities.py b/backend/tests/api/test_securities.py new file mode 100644 index 0000000..a1b1b64 --- /dev/null +++ b/backend/tests/api/test_securities.py @@ -0,0 +1,263 @@ +"""`GET /api/v1/securities` via the Flask test client: authn (any session, not customer-only), +the response shape, `source`/`status` provenance pass-through (`SimulatedBadge`'s exact input), +and pagination round-trip. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import UTC, date, datetime + +import pyotp +import pytest +from flask.testing import FlaskClient +from sqlalchemy import Engine, text +from sqlalchemy.orm import Session + +from app.core.money import Price +from app.models.identity.staff import Staff, StaffRole +from app.models.marketdata.daily_close import DailyClose, DailyCloseStatus, MarketDataSource +from app.models.marketdata.security import Security, SecurityAssetClass, SecurityStatus +from app.services.identity.auth import hash_password + +CUSTOMER_EMAIL = "securities-customer@trueup.example" +CUSTOMER_PASSWORD = "correct-horse-battery" +STAFF_EMAIL = "securities-adviser@trueup.example" +STAFF_PASSWORD = "another-strong-password" + +_TABLES = [Security.__table__, DailyClose.__table__] + + +@pytest.fixture(autouse=True) +def _securities_tables(owner_engine: Engine) -> Iterator[None]: + for table in _TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield None + with owner_engine.begin() as connection: + for table in reversed(_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE')) + + +def _register_and_login( + client: FlaskClient, *, email: str = CUSTOMER_EMAIL, password: str = CUSTOMER_PASSWORD +) -> str: + register_response = client.post( + "/api/v1/auth/register", json={"email": email, "password": password} + ) + assert register_response.status_code == 201 + login_response = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert login_response.status_code == 200 + csrf_token: str = login_response.get_json()["csrf_token"] + return csrf_token + + +def _insert_security( + owner_engine: Engine, *, symbol: str, status: SecurityStatus = SecurityStatus.ACTIVE +) -> Security: + session = Session(bind=owner_engine, expire_on_commit=False) + security = Security( + symbol=symbol, name=f"{symbol} Co", asset_class=SecurityAssetClass.EQUITY, status=status + ) + session.add(security) + session.commit() + session.close() + return security + + +def _insert_close( + owner_engine: Engine, + *, + security_id: object, + market_date: date, + recorded_at: datetime, + price: str = "100.00", + source: MarketDataSource = MarketDataSource.LIVE, + status: DailyCloseStatus = DailyCloseStatus.CONFIRMED, +) -> DailyClose: + session = Session(bind=owner_engine, expire_on_commit=False) + close = DailyClose( + security_id=security_id, + market_date=market_date, + close_price=Price(price), + source=source, + status=status, + recorded_at=recorded_at, + ) + session.add(close) + session.commit() + session.close() + return close + + +# --- authentication -------------------------------------------------------------------------- + + +def test_list_securities_requires_authentication(api_client: FlaskClient) -> None: + response = api_client.get("/api/v1/securities") + + assert response.status_code == 401 + + +def test_list_securities_accessible_to_a_staff_session( + api_client: FlaskClient, owner_engine: Engine +) -> None: + """Not tenant-scoped: a staff session may read it too, with no `customer_id` to supply.""" + session = Session(bind=owner_engine, expire_on_commit=False) + session.add( + Staff( + email=STAFF_EMAIL, + password_hash=hash_password(STAFF_PASSWORD), + role=StaffRole.adviser, + ) + ) + session.commit() + session.close() + + login_response = api_client.post( + "/api/v1/auth/login", json={"email": STAFF_EMAIL, "password": STAFF_PASSWORD} + ) + assert login_response.status_code == 200 + pending_csrf = login_response.get_json()["csrf_token"] + secret = api_client.post( + "/api/v1/auth/mfa/enroll", headers={"X-CSRFToken": pending_csrf} + ).get_json()["secret"] + code = pyotp.TOTP(secret).now() + verify = api_client.post( + "/api/v1/auth/mfa/verify", json={"code": code}, headers={"X-CSRFToken": pending_csrf} + ) + assert verify.status_code == 200 + + response = api_client.get("/api/v1/securities") + + assert response.status_code == 200 + assert response.get_json() == {"securities": [], "next_cursor": None} + + +# --- happy path / response shape --------------------------------------------------------------- + + +def test_list_securities_happy_path_with_a_live_close( + api_client: FlaskClient, owner_engine: Engine +) -> None: + _register_and_login(api_client) + security = _insert_security(owner_engine, symbol="AAPL") + _insert_close( + owner_engine, + security_id=security.id, + market_date=date(2026, 1, 10), + recorded_at=datetime(2026, 1, 10, 21, 0, tzinfo=UTC), + price="150.25", + ) + + response = api_client.get("/api/v1/securities") + + assert response.status_code == 200 + assert response.get_json() == { + "securities": [ + { + "security_id": str(security.id), + "symbol": "AAPL", + "name": "AAPL Co", + "asset_class": "equity", + "last_close": { + "price": "150.250000", + "market_date": "2026-01-10", + "source": "live", + "status": "confirmed", + }, + } + ], + "next_cursor": None, + } + + +def test_list_securities_happy_path_with_no_close_is_null( + api_client: FlaskClient, owner_engine: Engine +) -> None: + _register_and_login(api_client) + security = _insert_security(owner_engine, symbol="ZZZ") + + response = api_client.get("/api/v1/securities") + + assert response.status_code == 200 + assert response.get_json() == { + "securities": [ + { + "security_id": str(security.id), + "symbol": "ZZZ", + "name": "ZZZ Co", + "asset_class": "equity", + "last_close": None, + } + ], + "next_cursor": None, + } + + +def test_list_securities_simulated_source_passes_through_untouched( + api_client: FlaskClient, owner_engine: Engine +) -> None: + _register_and_login(api_client) + security = _insert_security(owner_engine, symbol="SIMU") + _insert_close( + owner_engine, + security_id=security.id, + market_date=date(2026, 1, 10), + recorded_at=datetime(2026, 1, 10, 21, 0, tzinfo=UTC), + source=MarketDataSource.SIMULATED, + ) + + response = api_client.get("/api/v1/securities") + + assert response.status_code == 200 + body = response.get_json() + assert body["securities"][0]["last_close"]["source"] == "simulated" + + +def test_list_securities_never_includes_an_inactive_security( + api_client: FlaskClient, owner_engine: Engine +) -> None: + _register_and_login(api_client) + _insert_security(owner_engine, symbol="DELISTED", status=SecurityStatus.INACTIVE) + + response = api_client.get("/api/v1/securities") + + assert response.status_code == 200 + assert response.get_json() == {"securities": [], "next_cursor": None} + + +# --- pagination -------------------------------------------------------------------------------- + + +def test_list_securities_pagination_round_trip( + api_client: FlaskClient, owner_engine: Engine +) -> None: + _register_and_login(api_client) + for symbol in ("AAA", "BBB", "CCC"): + _insert_security(owner_engine, symbol=symbol) + + first = api_client.get("/api/v1/securities", query_string={"limit": 2}) + assert first.status_code == 200 + first_body = first.get_json() + assert [s["symbol"] for s in first_body["securities"]] == ["AAA", "BBB"] + assert first_body["next_cursor"] is not None + + second = api_client.get( + "/api/v1/securities", query_string={"limit": 2, "after": first_body["next_cursor"]} + ) + assert second.status_code == 200 + second_body = second.get_json() + assert [s["symbol"] for s in second_body["securities"]] == ["CCC"] + assert second_body["next_cursor"] is None + + +# --- validation boundaries ----------------------------------------------------------------------- + + +def test_list_securities_rejects_limit_over_the_maximum(api_client: FlaskClient) -> None: + _register_and_login(api_client) + + response = api_client.get("/api/v1/securities", query_string={"limit": 201}) + + assert response.status_code == 422 + assert response.get_json()["code"] == "validation_failed" diff --git a/backend/tests/integration/test_security_catalog_repositories.py b/backend/tests/integration/test_security_catalog_repositories.py new file mode 100644 index 0000000..bac07ba --- /dev/null +++ b/backend/tests/integration/test_security_catalog_repositories.py @@ -0,0 +1,191 @@ +"""`SecurityRepository.list_active`/`DailyCloseRepository.latest_for_securities` against real +Postgres (backing `GET /api/v1/securities`): the `status` filter, keyset pagination, and the +`market_date`-before-`recorded_at` ordering rule for "latest close" -- a late correction to an old +date must never look newer than a genuinely newer close. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, date, datetime + +import pytest +from sqlalchemy import event, text + +from app.core.db import DbRole +from app.core.money import Price +from app.core.uow import SessionRole +from app.models.marketdata import MarketDataUnitOfWork +from app.models.marketdata.daily_close import DailyClose, DailyCloseStatus, MarketDataSource +from app.models.marketdata.security import Security, SecurityAssetClass, SecurityStatus + +CATALOG_TABLES = [Security.__table__, DailyClose.__table__] + + +@pytest.fixture(autouse=True) +def _catalog_tables(owner_engine) -> Iterator[None]: + for table in CATALOG_TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield None + with owner_engine.begin() as connection: + for table in reversed(CATALOG_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE')) + + +def _owner_uow() -> MarketDataUnitOfWork: + return MarketDataUnitOfWork(customer_id=None, role=SessionRole.ADMIN, db_role=DbRole.OWNER) + + +def _insert_security( + uow: MarketDataUnitOfWork, *, symbol: str, status: SecurityStatus = SecurityStatus.ACTIVE +) -> Security: + security = Security( + symbol=symbol, name=f"{symbol} Co", asset_class=SecurityAssetClass.EQUITY, status=status + ) + uow.session.add(security) + uow.session.flush() + return security + + +def _insert_close( + uow: MarketDataUnitOfWork, + *, + security_id: uuid.UUID, + market_date: date, + recorded_at: datetime, + price: str = "100.00", + source: MarketDataSource = MarketDataSource.LIVE, + status: DailyCloseStatus = DailyCloseStatus.CONFIRMED, +) -> DailyClose: + close = DailyClose( + security_id=security_id, + market_date=market_date, + close_price=Price(price), + source=source, + status=status, + recorded_at=recorded_at, + ) + uow.session.add(close) + uow.session.flush() + return close + + +@contextmanager +def _query_counter(uow: MarketDataUnitOfWork) -> Iterator[list[int]]: + """Yields a one-element list holding the number of statements executed against `uow`'s + connection while the `with` block is open -- mutable so the caller can read it after.""" + engine = uow.session.get_bind() + counts = [0] + + def _increment(*_args: object, **_kwargs: object) -> None: + counts[0] += 1 + + event.listen(engine, "before_cursor_execute", _increment) + try: + yield counts + finally: + event.remove(engine, "before_cursor_execute", _increment) + + +# --- list_active: status filter + keyset pagination ------------------------------------------- + + +def test_list_active_filters_status_and_paginates_by_keyset() -> None: + with _owner_uow() as uow: + aaa = _insert_security(uow, symbol="AAA") + _insert_security(uow, symbol="BBB", status=SecurityStatus.INACTIVE) + ccc = _insert_security(uow, symbol="CCC") + ddd = _insert_security(uow, symbol="DDD") + eee = _insert_security(uow, symbol="EEE") + + first_page = uow.securities.list_active(limit=2, after=None) + + # limit + 1 rows: proves a further page exists, and the inactive row never appears. + assert [s.symbol for s in first_page] == ["AAA", "CCC", "DDD"] + assert aaa.id in {s.id for s in first_page} + + second_page = uow.securities.list_active(limit=2, after=(ccc.symbol, ccc.id)) + + # Picks up exactly where the first page left off -- DDD, EEE, no inactive BBB anywhere. + assert [s.symbol for s in second_page] == ["DDD", "EEE"] + assert {s.id for s in second_page} == {ddd.id, eee.id} + + +# --- latest_for_securities: market_date beats recorded_at -------------------------------------- + + +def test_latest_for_securities_prefers_newer_market_date_over_newer_recorded_at() -> None: + with _owner_uow() as uow: + security = _insert_security(uow, symbol="AAA") + # Older market_date, but recorded very recently. + _insert_close( + uow, + security_id=security.id, + market_date=date(2026, 1, 5), + recorded_at=datetime(2026, 1, 7, 12, 0, tzinfo=UTC), + price="100.00", + ) + # Newer market_date, recorded earlier -- this is the true "latest" close. + newer_date_close = _insert_close( + uow, + security_id=security.id, + market_date=date(2026, 1, 6), + recorded_at=datetime(2026, 1, 5, 9, 0, tzinfo=UTC), + price="101.00", + ) + + result = uow.daily_closes.latest_for_securities([security.id]) + + assert result[security.id].id == newer_date_close.id + assert result[security.id].close_price == Price("101.00") + + +def test_latest_for_securities_prefers_late_correction_on_the_same_market_date() -> None: + with _owner_uow() as uow: + security = _insert_security(uow, symbol="AAA") + _insert_close( + uow, + security_id=security.id, + market_date=date(2026, 1, 10), + recorded_at=datetime(2026, 1, 10, 16, 0, tzinfo=UTC), + price="100.00", + ) + # A late-arriving correction to the same market_date -- newer recorded_at wins here, + # since market_date is tied. + correction = _insert_close( + uow, + security_id=security.id, + market_date=date(2026, 1, 10), + recorded_at=datetime(2026, 1, 11, 9, 0, tzinfo=UTC), + price="105.00", + ) + + result = uow.daily_closes.latest_for_securities([security.id]) + + assert result[security.id].id == correction.id + assert result[security.id].close_price == Price("105.00") + + +def test_latest_for_securities_returns_empty_dict_for_empty_input() -> None: + with _owner_uow() as uow: + assert uow.daily_closes.latest_for_securities([]) == {} + + +def test_latest_for_securities_issues_exactly_one_query_for_many_securities() -> None: + with _owner_uow() as uow: + securities = [_insert_security(uow, symbol=f"SYM{i}") for i in range(5)] + for security in securities: + _insert_close( + uow, + security_id=security.id, + market_date=date(2026, 1, 10), + recorded_at=datetime(2026, 1, 10, 16, 0, tzinfo=UTC), + ) + + with _query_counter(uow) as counts: + result = uow.daily_closes.latest_for_securities([s.id for s in securities]) + + assert counts[0] == 1 + assert set(result.keys()) == {s.id for s in securities} From 0ed765ad22ac4649e80a97180fa0371818286fa0 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Tue, 8 Sep 2026 20:43:12 -0700 Subject: [PATCH 03/14] feat(orders): wire specific-ID tax-lot designation at sell time (FR-20/ADR-4) Lets a customer name which tax lots to sell at order-placement time. The consumption-time domain logic in LotConsumptionService already implemented and tested the specific-ID branch -- this wires the request path to it: - order.designated_lot_ids (nullable UUID array; NULL means FIFO, unchanged for every existing order) - OrderRepository.open_sell_orders, mirroring open_buy_orders - LotDesignationService validates side, shape (non-empty, no duplicates, <=50 lots), ownership, and coverage net of what other open sell orders have already claimed from the same lots -- no FIFO fallback for a shortfall, ever - POST /orders accepts lot_ids, maps each new validation error to its own 422 code, and echoes designated_lot_ids in the response - trade_update_handler passes order.designated_lot_ids through to record_sell_fill on every fill event Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HV5c66D3RU7zTqGjdtBnFz --- ...483b78ace2_add_order_designated_lot_ids.py | 33 ++ backend/app/controllers/api/orders.py | 36 +++ backend/app/models/orders/order.py | 21 +- .../orders/lot_designation_service.py | 123 ++++++++ .../services/orders/trade_update_handler.py | 1 + backend/app/views/orders.py | 2 + backend/tests/api/test_orders.py | 175 +++++++++++ .../test_lot_consumption_service.py | 53 ++++ .../unit/test_lot_designation_service.py | 294 ++++++++++++++++++ 9 files changed, 737 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/20260908_2034-cd483b78ace2_add_order_designated_lot_ids.py create mode 100644 backend/app/services/orders/lot_designation_service.py create mode 100644 backend/tests/unit/test_lot_designation_service.py diff --git a/backend/alembic/versions/20260908_2034-cd483b78ace2_add_order_designated_lot_ids.py b/backend/alembic/versions/20260908_2034-cd483b78ace2_add_order_designated_lot_ids.py new file mode 100644 index 0000000..0a4ea6c --- /dev/null +++ b/backend/alembic/versions/20260908_2034-cd483b78ace2_add_order_designated_lot_ids.py @@ -0,0 +1,33 @@ +"""add order.designated_lot_ids + +Revision ID: cd483b78ace2 +Revises: 24a90b294386 +Create Date: 2026-09-08 20:34:28.910779 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'cd483b78ace2' +down_revision: str | Sequence[str] | None = '24a90b294386' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('order', sa.Column('designated_lot_ids', postgresql.ARRAY(sa.UUID()), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('order', 'designated_lot_ids') + # ### end Alembic commands ### diff --git a/backend/app/controllers/api/orders.py b/backend/app/controllers/api/orders.py index af794d0..7ceb960 100644 --- a/backend/app/controllers/api/orders.py +++ b/backend/app/controllers/api/orders.py @@ -41,8 +41,17 @@ from app.models.orders.order import Order, OrderSide, OrderStatus from app.models.orders.order_event import OrderEvent, OrderEventType from app.services.ledger.cash_policy_service import CashPolicyService +from app.services.lots.lot_consumption_service import UnknownTaxLotError from app.services.orders.approval_hold_service import ApprovalHoldService from app.services.orders.holds_provider import OrderHoldsProvider +from app.services.orders.lot_designation_service import ( + DuplicateLotDesignationError, + EmptyLotDesignationError, + InsufficientDesignatedLotsError, + LotDesignationNotAllowedForBuyError, + LotDesignationService, + TooManyDesignatedLotsError, +) from app.services.orders.order_service import ( CustomerNotEligibleError, InsufficientInvestableCashError, @@ -75,6 +84,8 @@ class CreateOrderRequest(BaseModel): side: OrderSide quantity: Units reference_price: Price + lot_ids: list[uuid.UUID] | None = None + """Specific-ID tax-lot designation (FR-20/ADR-4); sell-only, `None` means FIFO.""" def _resolve_customer_id() -> uuid.UUID: @@ -175,6 +186,11 @@ def _to_order_response(uow: OrdersUnitOfWork, order: Order) -> OrderResponse: filled_quantity=order.filled_quantity, average_fill_price=order.average_fill_price, client_order_id=order.client_order_id, + designated_lot_ids=( + [str(lot_id) for lot_id in order.designated_lot_ids] + if order.designated_lot_ids + else None + ), created_at=order.created_at, updated_at=order.updated_at, ) @@ -210,6 +226,13 @@ def create_order() -> Any: return jsonify(body), status try: + designated_lot_ids = LotDesignationService(uow).validate( + customer_id=customer_id, + security_id=data.security_id, + side=data.side, + quantity=data.quantity, + lot_ids=data.lot_ids, + ) service = _order_service(uow) order = service.create_order( OrderCreationRequest( @@ -220,6 +243,7 @@ def create_order() -> Any: reference_price=data.reference_price, ) ) + order.designated_lot_ids = designated_lot_ids if order.status is OrderStatus.APPROVED: service.enqueue_submission(order) except CustomerNotEligibleError as exc: @@ -228,6 +252,18 @@ def create_order() -> Any: ) from exc except InsufficientInvestableCashError as exc: raise ValidationError(str(exc), code="insufficient_investable_cash") from exc + except LotDesignationNotAllowedForBuyError as exc: + raise ValidationError(str(exc), code="lot_designation_not_allowed_for_buy") from exc + except EmptyLotDesignationError as exc: + raise ValidationError(str(exc), code="empty_lot_designation") from exc + except DuplicateLotDesignationError as exc: + raise ValidationError(str(exc), code="duplicate_lot_id") from exc + except TooManyDesignatedLotsError as exc: + raise ValidationError(str(exc), code="too_many_designated_lots") from exc + except UnknownTaxLotError as exc: + raise ValidationError(str(exc), code="unknown_tax_lot") from exc + except InsufficientDesignatedLotsError as exc: + raise ValidationError(str(exc), code="insufficient_designated_lots") from exc view = _to_order_response(uow, order) body = view.model_dump(mode="json") diff --git a/backend/app/models/orders/order.py b/backend/app/models/orders/order.py index a8df828..0b320b2 100644 --- a/backend/app/models/orders/order.py +++ b/backend/app/models/orders/order.py @@ -16,7 +16,7 @@ from sqlalchemy import DDL, DateTime, ForeignKey, Index, String, event, func, select from sqlalchemy import Enum as SQLAlchemyEnum -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import ARRAY, UUID from sqlalchemy.orm import Mapped, mapped_column from app.core.money import Price, PriceType, Units, UnitsType @@ -85,6 +85,13 @@ class Order(Base): UnitsType, nullable=False, default=Units("0") ) average_fill_price: Mapped[Price | None] = mapped_column(PriceType, nullable=True) + # NULL means FIFO (ADR 4 default), unchanged for every order placed before this column + # existed. A property of the order request itself (same category as side/quantity_requested), + # not a new aggregate -- no join table, no FK to tax_lot (see the no-FK note on security_id + # above; same customer-tenancy-boundary reason applies here). + designated_lot_ids: Mapped[list[uuid.UUID] | None] = mapped_column( + ARRAY(UUID(as_uuid=True)), nullable=True + ) client_order_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() @@ -153,6 +160,18 @@ def open_buy_orders(self, customer_id: uuid.UUID) -> list[Order]: ) return list(self.session.execute(statement).scalars().all()) + def open_sell_orders(self, customer_id: uuid.UUID) -> list[Order]: + """Sell orders past the approval-hold window but not yet resolved (mirrors + `open_buy_orders`) -- `LotDesignationService`'s netting check (FR-20).""" + statement = self._tenant_scoped(select(Order)).where( + Order.customer_id == customer_id, + Order.side == OrderSide.SELL, + Order.status.in_( + (OrderStatus.SUBMITTED, OrderStatus.ACCEPTED, OrderStatus.PARTIALLY_FILLED) + ), + ) + return list(self.session.execute(statement).scalars().all()) + __all__ = [ "TERMINAL_NON_FILLED_STATUSES", diff --git a/backend/app/services/orders/lot_designation_service.py b/backend/app/services/orders/lot_designation_service.py new file mode 100644 index 0000000..f060914 --- /dev/null +++ b/backend/app/services/orders/lot_designation_service.py @@ -0,0 +1,123 @@ +"""Validates a customer's specific-ID lot designation at order-placement time (FR-20, ADR 4). + +The consumption-time domain logic (`LotConsumptionService.record_sell_fill`) already validates +ownership and consumes exactly the named lots; this service only decides whether a designation is +even admissible to *place* -- side, shape, and whether the named lots (net of what other open sell +orders have already claimed) can actually cover the requested quantity. There is no FIFO fallback +here for an uncovered remainder: an explicit designation determines the customer's realized +gain/loss, so a shortfall is always rejected, never silently substituted. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.core.money import Units +from app.models.orders.order import OrderSide +from app.services.lots.lot_consumption_service import UnknownTaxLotError + +if TYPE_CHECKING: + import uuid + from collections.abc import Sequence + + from app.services.orders.uow import OrdersUnitOfWork + +_MAX_DESIGNATED_LOTS = 50 +"""OWASP API4 (unbounded resource consumption): checked before any DB round-trip.""" + + +class LotDesignationNotAllowedForBuyError(RuntimeError): + """`lot_ids` was supplied for a buy order; specific-ID designation only applies to sells.""" + + +class EmptyLotDesignationError(RuntimeError): + """`lot_ids` was an explicit empty list -- a client bug, distinct from omitting the field + (which means FIFO).""" + + +class DuplicateLotDesignationError(RuntimeError): + """`lot_ids` named the same tax lot more than once.""" + + +class TooManyDesignatedLotsError(RuntimeError): + """`lot_ids` exceeded the per-order cap.""" + + +class InsufficientDesignatedLotsError(RuntimeError): + """The named lots, net of what other open sell orders have already claimed from them, cannot + cover the requested quantity. No FIFO fallback: the order is rejected outright.""" + + +class LotDesignationService: + def __init__(self, uow: OrdersUnitOfWork) -> None: + self._uow = uow + + def validate( + self, + *, + customer_id: uuid.UUID, + security_id: uuid.UUID, + side: OrderSide, + quantity: Units, + lot_ids: Sequence[uuid.UUID] | None, + ) -> list[uuid.UUID] | None: + """Returns the validated designation to store on `Order.designated_lot_ids`, or `None` + unchanged when the caller omitted one (FIFO).""" + if lot_ids is None: + return None + if side is not OrderSide.SELL: + raise LotDesignationNotAllowedForBuyError( + "lot_ids may only be designated on a sell order" + ) + if len(lot_ids) == 0: + raise EmptyLotDesignationError( + "lot_ids was an explicit empty list; omit the field for FIFO" + ) + if len(set(lot_ids)) != len(lot_ids): + raise DuplicateLotDesignationError("lot_ids contains a duplicate tax lot id") + if len(lot_ids) > _MAX_DESIGNATED_LOTS: + raise TooManyDesignatedLotsError( + f"lot_ids named {len(lot_ids)} lots, more than the {_MAX_DESIGNATED_LOTS} allowed" + ) + + locked = self._uow.tax_lots.lock_by_ids(lot_ids) + named_lots = [] + for lot_id in lot_ids: + lot = locked.get(lot_id) + if lot is None or lot.customer_id != customer_id or lot.security_id != security_id: + raise UnknownTaxLotError( + f"no tax lot found for id={lot_id!r} belonging to customer {customer_id!r} " + f"/ security {security_id!r}" + ) + named_lots.append(lot) + + named_total = Units("0") + for lot in named_lots: + named_total = named_total + lot.quantity_remaining + + named_ids = set(lot_ids) + already_committed = Units("0") + for open_sell in self._uow.orders.open_sell_orders(customer_id): + open_designation = open_sell.designated_lot_ids + if open_designation and named_ids.intersection(open_designation): + already_committed = already_committed + ( + open_sell.quantity_requested - open_sell.filled_quantity + ) + + if named_total - already_committed < quantity: + raise InsufficientDesignatedLotsError( + f"named lots cover {named_total - already_committed} after netting against other " + f"open sell orders, less than the requested {quantity}" + ) + + return list(lot_ids) + + +__all__ = [ + "DuplicateLotDesignationError", + "EmptyLotDesignationError", + "InsufficientDesignatedLotsError", + "LotDesignationNotAllowedForBuyError", + "LotDesignationService", + "TooManyDesignatedLotsError", +] diff --git a/backend/app/services/orders/trade_update_handler.py b/backend/app/services/orders/trade_update_handler.py index 30107e0..07824b3 100644 --- a/backend/app/services/orders/trade_update_handler.py +++ b/backend/app/services/orders/trade_update_handler.py @@ -155,6 +155,7 @@ def _record_lot_fill( quantity=quantity, price=price, filled_at=message.timestamp, + designated_lot_ids=order.designated_lot_ids, ) diff --git a/backend/app/views/orders.py b/backend/app/views/orders.py index b0448c4..9d0d743 100644 --- a/backend/app/views/orders.py +++ b/backend/app/views/orders.py @@ -31,6 +31,8 @@ class OrderResponse(BaseModel): filled_quantity: Units average_fill_price: Price | None client_order_id: str + designated_lot_ids: list[str] | None + """Specific-ID tax-lot designation (FR-20/ADR-4); `None` means FIFO.""" created_at: datetime updated_at: datetime diff --git a/backend/tests/api/test_orders.py b/backend/tests/api/test_orders.py index fc9a691..a908ec7 100644 --- a/backend/tests/api/test_orders.py +++ b/backend/tests/api/test_orders.py @@ -261,6 +261,53 @@ def _record_fill( session.close() +def _seed_owned_tax_lot( + client: FlaskClient, + owner_engine: Engine, + csrf_token: str, + *, + lot_id: uuid.UUID, + quantity: str, +) -> None: + """Seeds a real, ownable tax lot for the already-authed customer via an actual buy order plus + fill (S5's `opening_fill_execution_id` FK requires a real `order_event` row) -- lot designation + validation needs a genuinely owned lot, not just a bare row.""" + buy_response = client.post( + "/api/v1/orders", + json=_create_payload(quantity=quantity), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + buy_order_id = uuid.UUID(buy_response.get_json()["id"]) + customer_id = uuid.UUID(buy_response.get_json()["customer_id"]) + execution_id = f"exec-open-{lot_id}" + _advance_order_to_submitted(owner_engine, buy_order_id, broker_order_id=f"broker-{lot_id}") + _record_fill( + owner_engine, buy_order_id, seq=2, execution_id=execution_id, + quantity=quantity, price="100.00", + ) + + session = Session(bind=owner_engine, expire_on_commit=False) + try: + session.add( + TaxLot( + id=lot_id, + customer_id=customer_id, + security_id=SECURITY_ID, + opening_fill_execution_id=execution_id, + quantity_opened=Units(quantity), + quantity_remaining=Units(quantity), + original_cost_basis=Money("100.00"), + adjusted_basis=Money("100.00"), + acquired_at=date(2026, 1, 5), + designation=LotDesignation.UNSPECIFIED, + designation_window_closes_at=datetime(2026, 1, 5, 23, 59, tzinfo=UTC), + ) + ) + session.commit() + finally: + session.close() + + # --- create: happy path -------------------------------------------------------------------- @@ -413,6 +460,134 @@ def test_create_order_rejects_invalid_input( assert response.status_code == 422 +# --- create: specific-ID lot designation (FR-20/ADR-4) --------------------------------------- + + +def test_create_sell_order_with_lot_ids_persists_and_echoes_the_designation( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + lot_id = uuid.uuid4() + _seed_owned_tax_lot(client, owner_engine, csrf_token, lot_id=lot_id, quantity="10") + + response = client.post( + "/api/v1/orders", + json=_create_payload(side="sell", quantity="10", lot_ids=[str(lot_id)]), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + assert response.status_code == 201 + body = response.get_json() + assert body["designated_lot_ids"] == [str(lot_id)] + + order_id = uuid.UUID(body["id"]) + session = Session(bind=owner_engine, expire_on_commit=False) + try: + order = session.get(Order, order_id) + assert order is not None + assert order.designated_lot_ids == [lot_id] + finally: + session.close() + + +def test_create_buy_order_with_lot_ids_is_rejected( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + lot_id = uuid.uuid4() + _seed_owned_tax_lot(client, owner_engine, csrf_token, lot_id=lot_id, quantity="10") + + response = client.post( + "/api/v1/orders", + json=_create_payload(side="buy", lot_ids=[str(lot_id)]), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "lot_designation_not_allowed_for_buy" + + +def test_create_sell_order_with_an_empty_lot_ids_list_is_rejected( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + + response = client.post( + "/api/v1/orders", + json=_create_payload(side="sell", lot_ids=[]), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "empty_lot_designation" + + +def test_create_sell_order_with_duplicate_lot_ids_is_rejected( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + lot_id = uuid.uuid4() + _seed_owned_tax_lot(client, owner_engine, csrf_token, lot_id=lot_id, quantity="10") + + response = client.post( + "/api/v1/orders", + json=_create_payload(side="sell", quantity="10", lot_ids=[str(lot_id), str(lot_id)]), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "duplicate_lot_id" + + +def test_create_sell_order_with_too_many_lot_ids_is_rejected( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + + response = client.post( + "/api/v1/orders", + json=_create_payload( + side="sell", lot_ids=[str(uuid.uuid4()) for _ in range(51)] + ), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "too_many_designated_lots" + + +def test_create_sell_order_naming_a_nonexistent_lot_is_rejected( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + + response = client.post( + "/api/v1/orders", + json=_create_payload(side="sell", lot_ids=[str(uuid.uuid4())]), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "unknown_tax_lot" + + +def test_create_sell_order_naming_lots_that_dont_cover_the_quantity_is_rejected( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + lot_id = uuid.uuid4() + _seed_owned_tax_lot(client, owner_engine, csrf_token, lot_id=lot_id, quantity="5") + + response = client.post( + "/api/v1/orders", + json=_create_payload(side="sell", quantity="10", lot_ids=[str(lot_id)]), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "insufficient_designated_lots" + + # --- create: authn/authz -------------------------------------------------------------------- diff --git a/backend/tests/integration/test_lot_consumption_service.py b/backend/tests/integration/test_lot_consumption_service.py index dcea852..a95d2d3 100644 --- a/backend/tests/integration/test_lot_consumption_service.py +++ b/backend/tests/integration/test_lot_consumption_service.py @@ -356,6 +356,59 @@ def test_record_sell_fill_honours_a_specific_lot_designation_over_fifo_order() - assert newer.designation is LotDesignation.SPECIFIC +def test_record_sell_fill_continues_the_same_designation_across_two_partial_fills() -> None: + """FR-20/ADR-4: a repeated `designated_lot_ids` across two sequential partial fills on one + sell order must continue consuming from where the first fill left off, drawing on + `TaxLot.quantity_remaining` persisted from the first fill's commit -- never re-consuming + already-drawn quantity and never falling back to FIFO for the remainder.""" + with _owner_uow() as uow: + customer_id = _insert_customer_with_cash(uow) + security_id = _insert_security(uow) + service = _service(uow) + + lot_a = _buy( + service, uow, + customer_id=customer_id, security_id=security_id, execution_id="exec-a", + quantity=Units("5"), price=Price("100.00"), filled_at=DAY_1, + ) + lot_b = _buy( + service, uow, + customer_id=customer_id, security_id=security_id, execution_id="exec-b", + quantity=Units("5"), price=Price("50.00"), filled_at=DAY_2, + ) + designation = [lot_a.id, lot_b.id] + + # First partial fill: 3 of 10 requested, entirely drawable from lot_a alone. + first_consumptions = _sell( + service, uow, customer_id=customer_id, security_id=security_id, + execution_id="exec-sell-1", quantity=Units("3"), price=Price("150.00"), + filled_at=DAY_3, designated_lot_ids=designation, + ) + uow.commit() + + assert len(first_consumptions) == 1 + assert first_consumptions[0].tax_lot_id == lot_a.id + assert first_consumptions[0].quantity_consumed == Units("3") + + with _owner_uow() as uow: + # Second partial fill: the remaining 7, same designation -- must pick up lot_a's last 2 + # units, then spill into lot_b for the other 5, never re-drawing lot_a's already-consumed 3. + service = _service(uow) + second_consumptions = _sell( + service, uow, customer_id=customer_id, security_id=security_id, + execution_id="exec-sell-2", quantity=Units("7"), price=Price("150.00"), + filled_at=DAY_4, designated_lot_ids=designation, + ) + uow.commit() + + by_lot = {c.tax_lot_id: c.quantity_consumed for c in second_consumptions} + assert by_lot[lot_a.id] == Units("2") + assert by_lot[lot_b.id] == Units("5") + + assert uow.session.get(TaxLot, lot_a.id).quantity_remaining == Units("0") + assert uow.session.get(TaxLot, lot_b.id).quantity_remaining == Units("0") + + def test_record_sell_fill_rejects_a_designated_lot_belonging_to_another_customer() -> None: with _owner_uow() as uow: customer_a = _insert_customer_with_cash(uow) diff --git a/backend/tests/unit/test_lot_designation_service.py b/backend/tests/unit/test_lot_designation_service.py new file mode 100644 index 0000000..1b76ff0 --- /dev/null +++ b/backend/tests/unit/test_lot_designation_service.py @@ -0,0 +1,294 @@ +"""`LotDesignationService.validate` (FR-20/ADR-4) -- pure validation logic against fake, in-memory +`tax_lots`/`orders` repositories, no database (mirrors `test_rebalance_order_service.py`'s +duck-typed-uow style).""" + +from __future__ import annotations + +import uuid + +import pytest + +from app.core.money import Units +from app.models.ledger.tax_lot import TaxLot +from app.models.orders.order import Order, OrderSide, OrderStatus, derive_client_order_id +from app.services.lots.lot_consumption_service import UnknownTaxLotError +from app.services.orders.lot_designation_service import ( + DuplicateLotDesignationError, + EmptyLotDesignationError, + InsufficientDesignatedLotsError, + LotDesignationNotAllowedForBuyError, + LotDesignationService, + TooManyDesignatedLotsError, +) + +CUSTOMER_ID = uuid.uuid4() +OTHER_CUSTOMER_ID = uuid.uuid4() +SECURITY_ID = uuid.uuid4() +OTHER_SECURITY_ID = uuid.uuid4() + + +class _FakeTaxLotRepo: + def __init__(self, lots: list[TaxLot]) -> None: + self._lots = {lot.id: lot for lot in lots} + + def lock_by_ids(self, lot_ids: list[uuid.UUID]) -> dict[uuid.UUID, TaxLot]: + return {lot_id: self._lots[lot_id] for lot_id in lot_ids if lot_id in self._lots} + + +class _FakeOrderRepo: + def __init__(self, open_sell_orders: list[Order]) -> None: + self._open_sell_orders = open_sell_orders + + def open_sell_orders(self, customer_id: uuid.UUID) -> list[Order]: + return [o for o in self._open_sell_orders if o.customer_id == customer_id] + + +class _FakeUow: + def __init__( + self, *, lots: list[TaxLot] | None = None, open_sell_orders: list[Order] | None = None + ) -> None: + self.tax_lots = _FakeTaxLotRepo(lots or []) + self.orders = _FakeOrderRepo(open_sell_orders or []) + + +def _lot(*, quantity_remaining: str, customer_id: uuid.UUID = CUSTOMER_ID, + security_id: uuid.UUID = SECURITY_ID) -> TaxLot: + return TaxLot( + id=uuid.uuid4(), + customer_id=customer_id, + security_id=security_id, + quantity_remaining=Units(quantity_remaining), + ) + + +def _open_sell( + *, designated_lot_ids: list[uuid.UUID] | None, quantity_requested: str, filled_quantity: str, + customer_id: uuid.UUID = CUSTOMER_ID, status: OrderStatus = OrderStatus.SUBMITTED, +) -> Order: + order_id = uuid.uuid4() + return Order( + id=order_id, + customer_id=customer_id, + security_id=SECURITY_ID, + side=OrderSide.SELL, + quantity_requested=Units(quantity_requested), + filled_quantity=Units(filled_quantity), + status=status, + designated_lot_ids=designated_lot_ids, + client_order_id=derive_client_order_id(order_id), + ) + + +def _service(uow: _FakeUow) -> LotDesignationService: + return LotDesignationService(uow) # type: ignore[arg-type] + + +# --- rule 0: omitted lot_ids means FIFO, unchanged ------------------------------------------- + + +def test_omitted_lot_ids_returns_none_unchanged() -> None: + service = _service(_FakeUow()) + + result = service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5"), lot_ids=None, + ) + + assert result is None + + +# --- rule 1: buy orders may never designate lots ---------------------------------------------- + + +def test_lot_ids_on_a_buy_order_is_rejected() -> None: + lot = _lot(quantity_remaining="10") + service = _service(_FakeUow(lots=[lot])) + + with pytest.raises(LotDesignationNotAllowedForBuyError): + service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.BUY, + quantity=Units("5"), lot_ids=[lot.id], + ) + + +# --- rule 2: an explicit empty list is a client bug -------------------------------------------- + + +def test_empty_lot_ids_list_is_rejected() -> None: + service = _service(_FakeUow()) + + with pytest.raises(EmptyLotDesignationError): + service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5"), lot_ids=[], + ) + + +# --- rule 3: duplicates ------------------------------------------------------------------------- + + +def test_duplicate_lot_ids_are_rejected() -> None: + lot = _lot(quantity_remaining="10") + service = _service(_FakeUow(lots=[lot])) + + with pytest.raises(DuplicateLotDesignationError): + service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5"), lot_ids=[lot.id, lot.id], + ) + + +# --- rule 4: too many lots (OWASP API4) --------------------------------------------------------- + + +def test_more_than_fifty_lot_ids_is_rejected() -> None: + service = _service(_FakeUow()) + lot_ids = [uuid.uuid4() for _ in range(51)] + + with pytest.raises(TooManyDesignatedLotsError): + service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5"), lot_ids=lot_ids, + ) + + +# --- rule 5: ownership --------------------------------------------------------------------------- + + +def test_nonexistent_lot_id_is_rejected() -> None: + service = _service(_FakeUow()) + + with pytest.raises(UnknownTaxLotError): + service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5"), lot_ids=[uuid.uuid4()], + ) + + +def test_lot_belonging_to_another_customer_is_rejected() -> None: + others_lot = _lot(quantity_remaining="10", customer_id=OTHER_CUSTOMER_ID) + service = _service(_FakeUow(lots=[others_lot])) + + with pytest.raises(UnknownTaxLotError): + service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5"), lot_ids=[others_lot.id], + ) + + +def test_lot_belonging_to_another_security_is_rejected() -> None: + wrong_security_lot = _lot(quantity_remaining="10", security_id=OTHER_SECURITY_ID) + service = _service(_FakeUow(lots=[wrong_security_lot])) + + with pytest.raises(UnknownTaxLotError): + service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5"), lot_ids=[wrong_security_lot.id], + ) + + +# --- rule 6: coverage, including the exact-boundary case ---------------------------------------- + + +def test_named_lots_covering_less_than_quantity_is_rejected() -> None: + lot = _lot(quantity_remaining="5") + service = _service(_FakeUow(lots=[lot])) + + with pytest.raises(InsufficientDesignatedLotsError): + service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5.000001"), lot_ids=[lot.id], + ) + + +def test_named_lots_exactly_covering_quantity_is_not_a_false_negative() -> None: + """The boundary: combined quantity_remaining == requested quantity must pass.""" + lot = _lot(quantity_remaining="5") + service = _service(_FakeUow(lots=[lot])) + + result = service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5"), lot_ids=[lot.id], + ) + + assert result == [lot.id] + + +def test_named_lots_covering_more_than_quantity_across_multiple_lots_succeeds() -> None: + lot_a = _lot(quantity_remaining="3") + lot_b = _lot(quantity_remaining="4") + service = _service(_FakeUow(lots=[lot_a, lot_b])) + + result = service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("6"), lot_ids=[lot_a.id, lot_b.id], + ) + + assert result == [lot_a.id, lot_b.id] + + +# --- rule 6: netting against other open sell orders (the double-claim case) -------------------- + + +def test_second_sell_order_naming_an_already_claimed_lot_is_rejected() -> None: + """Two sell orders name an overlapping lot; the second is rejected once the first has already + claimed enough of it that nothing remains to cover the second.""" + lot = _lot(quantity_remaining="10") + first_sell = _open_sell( + designated_lot_ids=[lot.id], quantity_requested="10", filled_quantity="0", + ) + service = _service(_FakeUow(lots=[lot], open_sell_orders=[first_sell])) + + with pytest.raises(InsufficientDesignatedLotsError): + service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("5"), lot_ids=[lot.id], + ) + + +def test_second_sell_order_naming_a_partially_claimed_lot_succeeds_for_the_remainder() -> None: + """The first open sell order has only partially claimed the lot's quantity (already filled + some of it) -- the second can still validate against what's left.""" + lot = _lot(quantity_remaining="10") + first_sell = _open_sell( + designated_lot_ids=[lot.id], quantity_requested="10", filled_quantity="4", + ) + service = _service(_FakeUow(lots=[lot], open_sell_orders=[first_sell])) + + result = service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("4"), lot_ids=[lot.id], + ) + + assert result == [lot.id] + + +def test_open_sell_order_naming_a_disjoint_lot_does_not_affect_netting() -> None: + lot = _lot(quantity_remaining="10") + other_lot = _lot(quantity_remaining="10") + unrelated_sell = _open_sell( + designated_lot_ids=[other_lot.id], quantity_requested="10", filled_quantity="0", + ) + service = _service(_FakeUow(lots=[lot], open_sell_orders=[unrelated_sell])) + + result = service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("10"), lot_ids=[lot.id], + ) + + assert result == [lot.id] + + +def test_a_fifo_open_sell_order_with_no_designation_does_not_affect_netting() -> None: + lot = _lot(quantity_remaining="10") + fifo_sell = _open_sell( + designated_lot_ids=None, quantity_requested="10", filled_quantity="0", + ) + service = _service(_FakeUow(lots=[lot], open_sell_orders=[fifo_sell])) + + result = service.validate( + customer_id=CUSTOMER_ID, security_id=SECURITY_ID, side=OrderSide.SELL, + quantity=Units("10"), lot_ids=[lot.id], + ) + + assert result == [lot.id] From a688ec5db85cda77fdbb1c0f4f646bc9159c85f3 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Tue, 8 Sep 2026 21:44:11 -0700 Subject: [PATCH 04/14] feat(api): add cursor pagination to 5 unbounded customer list endpoints GET /orders, /lots, /funding/history, /valuation/history, and /statements returned every row for a customer with no upper bound. Each now accepts ?limit=&cursor= (default page size 50, max 200) and adds a purely-additive next_cursor field to its response, reusing app/core/pagination.py's established keyset (limit+1 fetch, opaque cursor) convention. Orders and funding history gained a new stability tiebreak (id) where their prior ordering had none; statements' existing tiebreak moved from publish_watermark to id per the paginated cursor-key contract. --- backend/app/controllers/api/funding.py | 54 ++++- backend/app/controllers/api/lots.py | 43 +++- backend/app/controllers/api/orders.py | 36 +++- backend/app/controllers/api/statements.py | 57 ++++-- backend/app/controllers/api/valuation.py | 55 ++++- backend/app/models/ledger/tax_lot.py | 25 ++- backend/app/models/orders/order.py | 25 ++- .../models/restatement/published_snapshot.py | 30 ++- .../identity/funding_history_service.py | 31 ++- .../app/services/lots/lots_summary_service.py | 19 +- .../app/services/valuation/history_service.py | 42 +++- backend/app/views/funding.py | 1 + backend/app/views/lots.py | 1 + backend/app/views/orders.py | 1 + backend/app/views/statements.py | 1 + backend/app/views/valuation.py | 1 + backend/tests/api/test_funding.py | 68 ++++++- backend/tests/api/test_lots.py | 57 ++++++ backend/tests/api/test_orders.py | 94 +++++++++ backend/tests/api/test_statements.py | 176 ++++++++++++++++ backend/tests/api/test_valuation_api.py | 92 +++++++++ .../test_funding_history_service.py | 140 ++++++++++++- .../test_order_repository_pagination.py | 107 ++++++++++ ...ublished_snapshot_repository_pagination.py | 129 ++++++++++++ .../test_tax_lot_repository_pagination.py | 148 ++++++++++++++ ...st_valuation_history_service_pagination.py | 189 ++++++++++++++++++ .../tests/unit/test_pagination_cursor_keys.py | 94 +++++++++ 27 files changed, 1641 insertions(+), 75 deletions(-) create mode 100644 backend/tests/api/test_statements.py create mode 100644 backend/tests/integration/test_order_repository_pagination.py create mode 100644 backend/tests/integration/test_published_snapshot_repository_pagination.py create mode 100644 backend/tests/integration/test_tax_lot_repository_pagination.py create mode 100644 backend/tests/integration/test_valuation_history_service_pagination.py create mode 100644 backend/tests/unit/test_pagination_cursor_keys.py diff --git a/backend/app/controllers/api/funding.py b/backend/app/controllers/api/funding.py index 05f6660..10eacde 100644 --- a/backend/app/controllers/api/funding.py +++ b/backend/app/controllers/api/funding.py @@ -7,7 +7,7 @@ from __future__ import annotations import uuid -from datetime import UTC, datetime +from datetime import UTC, date, datetime from typing import Any from flask import Blueprint, jsonify, request @@ -25,6 +25,7 @@ resolve_replay, ) from app.core.money import Money # noqa: TC001 -- Pydantic needs the real type at class-build time. +from app.core.pagination import decode_cursor, normalize_limit, paginate from app.core.uow import SessionRole from app.core.watermark import Watermark from app.extensions import DbRole, limiter @@ -109,6 +110,34 @@ def _session_role_and_customer_id( return SessionRole.ADMIN, None +def _parse_optional_limit(raw: str | None) -> int | None: + if raw is None: + return None + try: + return int(raw) + except ValueError as exc: + raise ValidationError("limit must be an integer") from exc + + +def _decode_funding_history_cursor(raw: str) -> tuple[date, datetime, uuid.UUID]: + decoded = decode_cursor(raw) + if ( + len(decoded) != 3 + or not isinstance(decoded[0], str) + or not isinstance(decoded[1], str) + or not isinstance(decoded[2], str) + ): + raise ValidationError("invalid pagination cursor") + try: + return ( + date.fromisoformat(decoded[0]), + datetime.fromisoformat(decoded[1]), + uuid.UUID(decoded[2]), + ) + except ValueError as exc: + raise ValidationError("invalid pagination cursor") from exc + + def _plaid_adapter() -> PlaidBankAdapter: settings = get_settings() if settings.plaid_client_id is None or settings.plaid_secret is None: @@ -191,9 +220,25 @@ def get_funding_history() -> Any: """Deposits and withdrawals, most-recent-first, with per-deposit settlement status (S2 §6).""" customer_id = _resolve_customer_id_for_get(request.args.get("customer_id")) role, uow_customer_id = _session_role_and_customer_id(customer_id) + limit = normalize_limit(_parse_optional_limit(request.args.get("limit"))) + after: tuple[date, datetime, uuid.UUID] | None = None + raw_cursor = request.args.get("cursor") + if raw_cursor: + after = _decode_funding_history_cursor(raw_cursor) with FundingUnitOfWork(customer_id=uow_customer_id, role=role, db_role=DbRole.APP) as uow: - entries = FundingHistoryService(uow).history(customer_id, as_of=Watermark.live()) + entries = FundingHistoryService(uow).history( + customer_id, as_of=Watermark.live(), limit=limit, after=after + ) + page = paginate( + entries, + limit=limit, + cursor_key=lambda e: ( + e.effective_date.isoformat(), + e.recorded_at.isoformat(), + str(e.journal_entry_id), + ), + ) view = FundingHistoryResponse( entries=[ FundingHistoryEntryResponse( @@ -208,8 +253,9 @@ def get_funding_history() -> Any: expected_settlement_date=e.expected_settlement_date, failure_reason=e.failure_reason, ) - for e in entries - ] + for e in page.items + ], + next_cursor=page.next_cursor, ) return jsonify(view.model_dump(mode="json")), 200 diff --git a/backend/app/controllers/api/lots.py b/backend/app/controllers/api/lots.py index 0431bba..e70c845 100644 --- a/backend/app/controllers/api/lots.py +++ b/backend/app/controllers/api/lots.py @@ -9,7 +9,7 @@ from __future__ import annotations import uuid -from datetime import UTC, datetime +from datetime import UTC, date, datetime from typing import Any from flask import Blueprint, jsonify, request @@ -18,6 +18,7 @@ from app.core.clock import MarketClock from app.core.errors import UnauthenticatedError, ValidationError +from app.core.pagination import decode_cursor, normalize_limit, paginate from app.core.security import requires_role from app.core.uow import SessionRole from app.extensions import limiter @@ -56,6 +57,25 @@ def _uow_customer_id(customer_id: uuid.UUID) -> uuid.UUID | None: return customer_id if _session_role() is SessionRole.CUSTOMER else None +def _parse_optional_limit(raw: str | None) -> int | None: + if raw is None: + return None + try: + return int(raw) + except ValueError as exc: + raise ValidationError("limit must be an integer") from exc + + +def _decode_lot_cursor(raw: str) -> tuple[date, uuid.UUID]: + decoded = decode_cursor(raw) + if len(decoded) != 2 or not isinstance(decoded[0], str) or not isinstance(decoded[1], str): + raise ValidationError("invalid pagination cursor") + try: + return date.fromisoformat(decoded[0]), uuid.UUID(decoded[1]) + except ValueError as exc: + raise ValidationError("invalid pagination cursor") from exc + + def _lot_to_response(item: LotSummary) -> LotResponse: return LotResponse( id=str(item.lot.id), @@ -92,13 +112,26 @@ def _lot_to_response(item: LotSummary) -> LotResponse: @requires_role("customer", *_STAFF_ROLES) def list_lots() -> Any: customer_id = _resolve_customer_id() + limit = normalize_limit(_parse_optional_limit(request.args.get("limit"))) + after: tuple[date, uuid.UUID] | None = None + raw_cursor = request.args.get("cursor") + if raw_cursor: + after = _decode_lot_cursor(raw_cursor) + with LotsUnitOfWork(customer_id=_uow_customer_id(customer_id), role=_session_role()) as uow: clock = MarketClock(CachedTradingCalendar(uow.calendar_cache)) as_of_date = clock.market_date(datetime.now(UTC)) - summary = LotsSummaryService(uow).summarize(customer_id, as_of_date=as_of_date) - items = [_lot_to_response(item) for item in summary.lots] - - view = LotsListResponse(lots=items) + summary = LotsSummaryService(uow).summarize( + customer_id, as_of_date=as_of_date, limit=limit, after=after + ) + page = paginate( + summary.lots, + limit=limit, + cursor_key=lambda item: (item.lot.acquired_at.isoformat(), str(item.lot.id)), + ) + items = [_lot_to_response(item) for item in page.items] + + view = LotsListResponse(lots=items, next_cursor=page.next_cursor) return jsonify(view.model_dump(mode="json")), 200 diff --git a/backend/app/controllers/api/orders.py b/backend/app/controllers/api/orders.py index 7ceb960..62d69aa 100644 --- a/backend/app/controllers/api/orders.py +++ b/backend/app/controllers/api/orders.py @@ -34,6 +34,7 @@ resolve_replay, ) from app.core.money import Price, Units +from app.core.pagination import decode_cursor, normalize_limit, paginate from app.core.security import audited from app.core.uow import SessionRole from app.extensions import limiter @@ -112,6 +113,25 @@ def _uow_customer_id(customer_id: uuid.UUID) -> uuid.UUID | None: return customer_id if _session_role() is SessionRole.CUSTOMER else None +def _parse_optional_limit(raw: str | None) -> int | None: + if raw is None: + return None + try: + return int(raw) + except ValueError as exc: + raise ValidationError("limit must be an integer") from exc + + +def _decode_order_cursor(raw: str) -> tuple[datetime, uuid.UUID]: + decoded = decode_cursor(raw) + if len(decoded) != 2 or not isinstance(decoded[0], str) or not isinstance(decoded[1], str): + raise ValidationError("invalid pagination cursor") + try: + return datetime.fromisoformat(decoded[0]), uuid.UUID(decoded[1]) + except ValueError as exc: + raise ValidationError("invalid pagination cursor") from exc + + def _order_service(uow: OrdersUnitOfWork) -> OrderService: return OrderService( uow, @@ -369,11 +389,23 @@ def list_orders() -> Any: raise ForbiddenError(f"role {current_user.role} not authorized for this endpoint") customer_id = _resolve_customer_id() + limit = normalize_limit(_parse_optional_limit(request.args.get("limit"))) + after: tuple[datetime, uuid.UUID] | None = None + raw_cursor = request.args.get("cursor") + if raw_cursor: + after = _decode_order_cursor(raw_cursor) + with OrdersUnitOfWork( customer_id=_uow_customer_id(customer_id), role=_session_role() ) as uow: - orders = uow.orders.list_for_customer(customer_id) - view = OrderListResponse(orders=[_to_order_response(uow, o) for o in orders]) + orders = uow.orders.list_for_customer(customer_id, limit=limit, after=after) + page = paginate( + orders, limit=limit, cursor_key=lambda o: (o.created_at.isoformat(), str(o.id)) + ) + view = OrderListResponse( + orders=[_to_order_response(uow, o) for o in page.items], + next_cursor=page.next_cursor, + ) return jsonify(view.model_dump(mode="json")), 200 diff --git a/backend/app/controllers/api/statements.py b/backend/app/controllers/api/statements.py index 202c9ca..bbbb5cc 100644 --- a/backend/app/controllers/api/statements.py +++ b/backend/app/controllers/api/statements.py @@ -16,6 +16,7 @@ from flask_login import current_user from app.core.errors import NotFoundError, UnauthenticatedError, ValidationError +from app.core.pagination import decode_cursor, normalize_limit, paginate from app.core.security import requires_role from app.core.uow import SessionRole from app.extensions import limiter @@ -56,28 +57,56 @@ def _uow_customer_id(customer_id: uuid.UUID) -> uuid.UUID | None: return customer_id if _session_role() is SessionRole.CUSTOMER else None +def _parse_optional_limit(raw: str | None) -> int | None: + if raw is None: + return None + try: + return int(raw) + except ValueError as exc: + raise ValidationError("limit must be an integer") from exc + + +def _decode_statement_cursor(raw: str) -> tuple[date, uuid.UUID]: + decoded = decode_cursor(raw) + if len(decoded) != 2 or not isinstance(decoded[0], str) or not isinstance(decoded[1], str): + raise ValidationError("invalid pagination cursor") + try: + return date.fromisoformat(decoded[0]), uuid.UUID(decoded[1]) + except ValueError as exc: + raise ValidationError("invalid pagination cursor") from exc + + @statements_bp.route("", methods=["GET"]) @limiter.limit("60 per minute") @requires_role("customer", *_STAFF_ROLES) def list_statements() -> Any: customer_id = _resolve_customer_id() + limit = normalize_limit(_parse_optional_limit(request.args.get("limit"))) + after: tuple[date, uuid.UUID] | None = None + raw_cursor = request.args.get("cursor") + if raw_cursor: + after = _decode_statement_cursor(raw_cursor) + with RestatementUnitOfWork( customer_id=_uow_customer_id(customer_id), role=_session_role() ) as uow: - snapshots = uow.published_snapshots.list_for_customer(customer_id) - - view = StatementsListResponse( - statements=[ - StatementSummaryResponse( - period_start=snapshot.period_start, - period_end=snapshot.period_end, - publish_watermark=snapshot.publish_watermark, - twr=snapshot.twr, - balance=snapshot.balance, - ) - for snapshot in snapshots - ] - ) + snapshots = uow.published_snapshots.list_for_customer(customer_id, limit=limit, after=after) + page = paginate( + snapshots, limit=limit, cursor_key=lambda s: (s.period_start.isoformat(), str(s.id)) + ) + view = StatementsListResponse( + statements=[ + StatementSummaryResponse( + period_start=snapshot.period_start, + period_end=snapshot.period_end, + publish_watermark=snapshot.publish_watermark, + twr=snapshot.twr, + balance=snapshot.balance, + ) + for snapshot in page.items + ], + next_cursor=page.next_cursor, + ) return jsonify(view.model_dump(mode="json")), 200 diff --git a/backend/app/controllers/api/valuation.py b/backend/app/controllers/api/valuation.py index 6223f9b..950bae5 100644 --- a/backend/app/controllers/api/valuation.py +++ b/backend/app/controllers/api/valuation.py @@ -5,7 +5,7 @@ from __future__ import annotations import uuid -from datetime import UTC, datetime +from datetime import UTC, date, datetime from typing import Any from flask import Blueprint, jsonify, request @@ -16,6 +16,7 @@ from app.core.clock import MarketClock from app.core.errors import UnauthenticatedError, ValidationError +from app.core.pagination import decode_cursor, normalize_limit, paginate from app.core.security import requires_role from app.core.uow import SessionRole from app.core.watermark import Watermark @@ -72,6 +73,34 @@ def _uow_customer_id(customer_id: uuid.UUID) -> uuid.UUID | None: return customer_id if _session_role() is SessionRole.CUSTOMER else None +def _parse_optional_limit(raw: str | None) -> int | None: + if raw is None: + return None + try: + return int(raw) + except ValueError as exc: + raise ValidationError("limit must be an integer") from exc + + +def _decode_history_cursor(raw: str) -> tuple[date, datetime, uuid.UUID]: + decoded = decode_cursor(raw) + if ( + len(decoded) != 3 + or not isinstance(decoded[0], str) + or not isinstance(decoded[1], str) + or not isinstance(decoded[2], str) + ): + raise ValidationError("invalid pagination cursor") + try: + return ( + date.fromisoformat(decoded[0]), + datetime.fromisoformat(decoded[1]), + uuid.UUID(decoded[2]), + ) + except ValueError as exc: + raise ValidationError("invalid pagination cursor") from exc + + @valuation_bp.route("/balance", methods=["GET"]) @limiter.limit("60 per minute") @requires_role("customer", *_STAFF_ROLES) @@ -124,10 +153,27 @@ def returns() -> Any: @requires_role("customer", *_STAFF_ROLES) def history() -> Any: customer_id = _resolve_customer_id() + limit = normalize_limit(_parse_optional_limit(request.args.get("limit"))) + after: tuple[date, datetime, uuid.UUID] | None = None + raw_cursor = request.args.get("cursor") + if raw_cursor: + after = _decode_history_cursor(raw_cursor) + with ValuationUnitOfWork( customer_id=_uow_customer_id(customer_id), role=_session_role() ) as uow: - entries = HistoryService(uow).history(customer_id, as_of=Watermark.live()) + entries = HistoryService(uow).history( + customer_id, as_of=Watermark.live(), limit=limit, after=after + ) + page = paginate( + entries, + limit=limit, + cursor_key=lambda e: ( + e.effective_date.isoformat(), + e.recorded_at.isoformat(), + str(e.posting_id), + ), + ) view = HistoryResponse( entries=[ @@ -139,8 +185,9 @@ def history() -> Any: quantity_units=entry.quantity_units, memo=entry.memo, ) - for entry in entries - ] + for entry in page.items + ], + next_cursor=page.next_cursor, ) return jsonify(view.model_dump(mode="json")), 200 diff --git a/backend/app/models/ledger/tax_lot.py b/backend/app/models/ledger/tax_lot.py index 4151d05..9e40a2b 100644 --- a/backend/app/models/ledger/tax_lot.py +++ b/backend/app/models/ledger/tax_lot.py @@ -24,12 +24,14 @@ String, event, select, + tuple_, ) from sqlalchemy import Enum as SQLAlchemyEnum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from app.core.money import Money, MoneyType, Units, UnitsType +from app.core.pagination import DEFAULT_PAGE_SIZE from app.core.repository import BaseRepository from app.models.base import Base from app.models.ledger._enum import enum_values @@ -174,13 +176,22 @@ def list_by_opening_execution_ids(self, execution_ids: Sequence[str]) -> list[Ta statement = select(TaxLot).where(TaxLot.opening_fill_execution_id.in_(execution_ids)) return list(self.session.execute(statement).scalars().all()) - def list_for_customer(self, customer_id: uuid.UUID) -> list[TaxLot]: - """Every lot ever opened for this customer, oldest-acquired first (`GET /api/v1/lots`, S8 §3).""" - statement = ( - select(TaxLot) - .where(TaxLot.customer_id == customer_id) - .order_by(TaxLot.acquired_at.asc(), TaxLot.id.asc()) - ) + def list_for_customer( + self, + customer_id: uuid.UUID, + *, + limit: int = DEFAULT_PAGE_SIZE, + after: tuple[date, uuid.UUID] | None = None, + ) -> list[TaxLot]: + """Every lot ever opened for this customer, oldest-acquired first (`GET /api/v1/lots`, S8 + §3), keyset-paginated on `(acquired_at, id)` ASC -- `id` was already the tiebreak here.""" + statement = select(TaxLot).where(TaxLot.customer_id == customer_id) + if after is not None: + after_acquired_at, after_id = after + statement = statement.where( + tuple_(TaxLot.acquired_at, TaxLot.id) > (after_acquired_at, after_id) + ) + statement = statement.order_by(TaxLot.acquired_at.asc(), TaxLot.id.asc()).limit(limit + 1) return list(self.session.execute(statement).scalars().all()) def list_by_ids(self, lot_ids: Sequence[uuid.UUID]) -> list[TaxLot]: diff --git a/backend/app/models/orders/order.py b/backend/app/models/orders/order.py index 0b320b2..2c6f182 100644 --- a/backend/app/models/orders/order.py +++ b/backend/app/models/orders/order.py @@ -14,12 +14,13 @@ from enum import StrEnum from typing import TYPE_CHECKING -from sqlalchemy import DDL, DateTime, ForeignKey, Index, String, event, func, select +from sqlalchemy import DDL, DateTime, ForeignKey, Index, String, event, func, select, tuple_ from sqlalchemy import Enum as SQLAlchemyEnum from sqlalchemy.dialects.postgresql import ARRAY, UUID from sqlalchemy.orm import Mapped, mapped_column from app.core.money import Price, PriceType, Units, UnitsType +from app.core.pagination import DEFAULT_PAGE_SIZE from app.core.repository import BaseRepository from app.models.base import Base from app.models.orders._enum import enum_values @@ -141,12 +142,22 @@ def get_for_update(self, order_id: uuid.UUID) -> Order | None: def get_by_client_order_id(self, client_order_id: str) -> Order | None: return self.session.query(Order).filter_by(client_order_id=client_order_id).first() - def list_for_customer(self, customer_id: uuid.UUID) -> list[Order]: - statement = ( - self._tenant_scoped(select(Order)) - .where(Order.customer_id == customer_id) - .order_by(Order.created_at.desc()) - ) + def list_for_customer( + self, + customer_id: uuid.UUID, + *, + limit: int = DEFAULT_PAGE_SIZE, + after: tuple[datetime, uuid.UUID] | None = None, + ) -> list[Order]: + """Keyset-paginated on `(created_at, id)` DESC (`GET /api/v1/orders`, S12 §8) -- `id` is + an added tiebreak: `created_at` alone is not unique across orders in the same instant.""" + statement = self._tenant_scoped(select(Order)).where(Order.customer_id == customer_id) + if after is not None: + after_created_at, after_id = after + statement = statement.where( + tuple_(Order.created_at, Order.id) < (after_created_at, after_id) + ) + statement = statement.order_by(Order.created_at.desc(), Order.id.desc()).limit(limit + 1) return list(self.session.execute(statement).scalars().all()) def open_buy_orders(self, customer_id: uuid.UUID) -> list[Order]: diff --git a/backend/app/models/restatement/published_snapshot.py b/backend/app/models/restatement/published_snapshot.py index 36257f0..20853bc 100644 --- a/backend/app/models/restatement/published_snapshot.py +++ b/backend/app/models/restatement/published_snapshot.py @@ -18,11 +18,12 @@ from typing import TYPE_CHECKING, Any from sqlalchemy import Date as SQLAlchemyDate -from sqlalchemy import DateTime, ForeignKey, Numeric +from sqlalchemy import DateTime, ForeignKey, Numeric, select, tuple_ from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column from app.core.money import Money, MoneyType +from app.core.pagination import DEFAULT_PAGE_SIZE from app.core.repository import BaseRepository from app.models.base import Base @@ -65,18 +66,27 @@ def __init__(self, uow: UnitOfWork) -> None: uow, entity=PublishedSnapshot, customer_id_column=PublishedSnapshot.customer_id ) - def list_for_customer(self, customer_id: uuid.UUID) -> list[PublishedSnapshot]: + def list_for_customer( + self, + customer_id: uuid.UUID, + *, + limit: int = DEFAULT_PAGE_SIZE, + after: tuple[date, uuid.UUID] | None = None, + ) -> list[PublishedSnapshot]: """`GET /api/v1/statements` (S6 §8) -- every snapshot ever published for this customer, including every watermark a period was republished under (FR-26: each one must stay - independently queryable).""" - return ( - self.session.query(PublishedSnapshot) - .filter_by(customer_id=customer_id) - .order_by( - PublishedSnapshot.period_start.desc(), PublishedSnapshot.publish_watermark.desc() + independently queryable). Keyset-paginated on `(period_start, id)` DESC (S12 §8).""" + statement = select(PublishedSnapshot).where(PublishedSnapshot.customer_id == customer_id) + if after is not None: + after_period_start, after_id = after + statement = statement.where( + tuple_(PublishedSnapshot.period_start, PublishedSnapshot.id) + < (after_period_start, after_id) ) - .all() - ) + statement = statement.order_by( + PublishedSnapshot.period_start.desc(), PublishedSnapshot.id.desc() + ).limit(limit + 1) + return list(self.session.execute(statement).scalars().all()) def latest_for_period( self, customer_id: uuid.UUID, period_start: date diff --git a/backend/app/services/identity/funding_history_service.py b/backend/app/services/identity/funding_history_service.py index f0acfcf..0016b0e 100644 --- a/backend/app/services/identity/funding_history_service.py +++ b/backend/app/services/identity/funding_history_service.py @@ -20,8 +20,9 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from sqlalchemy import select +from sqlalchemy import select, tuple_ +from app.core.pagination import DEFAULT_PAGE_SIZE from app.models.ledger.account import Account, AccountRole from app.models.ledger.journal_entry import JournalEntry, JournalEntryType from app.models.ledger.posting import Posting @@ -52,10 +53,22 @@ class FundingHistoryService: def __init__(self, uow: FundingUnitOfWork) -> None: self._uow = uow - def history(self, customer_id: uuid.UUID, *, as_of: Watermark) -> list[FundingHistoryEntry]: + def history( + self, + customer_id: uuid.UUID, + *, + as_of: Watermark, + limit: int = DEFAULT_PAGE_SIZE, + after: tuple[date, datetime, uuid.UUID] | None = None, + ) -> list[FundingHistoryEntry]: """`Account.role == CASH` is not an optimization: every deposit/withdrawal entry has two legs (cash and equity) with opposite-sign amounts, so without it this returns two rows per - journal entry and the signed `amount` becomes meaningless.""" + journal entry and the signed `amount` becomes meaningless. + + Keyset-paginated on `(effective_date, recorded_at, journal_entry_id)` DESC (`GET + /api/v1/funding/history`, S12 §8) -- the `journal_entry_id` tiebreak is required because + two entries can share both dates. Fetches `limit + 1` rows; the caller applies + `app.core.pagination.paginate()` to derive `next_cursor`.""" statement = ( select(JournalEntry, Posting) .join(Posting, Posting.journal_entry_id == JournalEntry.id) @@ -68,8 +81,18 @@ def history(self, customer_id: uuid.UUID, *, as_of: Watermark) -> list[FundingHi ), JournalEntry.recorded_at <= as_of.cutoff, ) - .order_by(JournalEntry.effective_date.desc(), JournalEntry.recorded_at.desc()) ) + if after is not None: + after_effective_date, after_recorded_at, after_id = after + statement = statement.where( + tuple_(JournalEntry.effective_date, JournalEntry.recorded_at, JournalEntry.id) + < (after_effective_date, after_recorded_at, after_id) + ) + statement = statement.order_by( + JournalEntry.effective_date.desc(), + JournalEntry.recorded_at.desc(), + JournalEntry.id.desc(), + ).limit(limit + 1) rows = self._uow.session.execute(statement).all() obligations_by_entry = { diff --git a/backend/app/services/lots/lots_summary_service.py b/backend/app/services/lots/lots_summary_service.py index 9bd4c53..8410f9b 100644 --- a/backend/app/services/lots/lots_summary_service.py +++ b/backend/app/services/lots/lots_summary_service.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING from app.core.money import Money, Units +from app.core.pagination import DEFAULT_PAGE_SIZE if TYPE_CHECKING: import uuid @@ -50,11 +51,23 @@ class LotsSummaryService: def __init__(self, uow: LotsUnitOfWork) -> None: self._uow = uow - def summarize(self, customer_id: uuid.UUID, *, as_of_date: date) -> LotsSummary: + def summarize( + self, + customer_id: uuid.UUID, + *, + as_of_date: date, + limit: int = DEFAULT_PAGE_SIZE, + after: tuple[date, uuid.UUID] | None = None, + ) -> LotsSummary: """`as_of_date` is resolved by the caller via MarketClock (matching ValuationService.value_book's controller-resolves-the-date precedent) -- this service never - converts a UTC instant into a market day itself.""" - lots = self._uow.tax_lots.list_for_customer(customer_id) + converts a UTC instant into a market day itself. + + `limit`/`after` page the underlying `(acquired_at, id)` keyset (`GET /api/v1/lots`, S12 + §8); the caller applies `app.core.pagination.paginate()` to `LotsSummary.lots` to slice the + page and derive `next_cursor` -- this method only forwards the fetch-`limit + 1` trick to + the repository so every enriched lot in the returned list stays a real candidate row.""" + lots = self._uow.tax_lots.list_for_customer(customer_id, limit=limit, after=after) lot_ids = [lot.id for lot in lots] consumptions_by_lot: dict[uuid.UUID, list[LotConsumption]] = defaultdict(list) diff --git a/backend/app/services/valuation/history_service.py b/backend/app/services/valuation/history_service.py index 6258bc9..ebc6fa2 100644 --- a/backend/app/services/valuation/history_service.py +++ b/backend/app/services/valuation/history_service.py @@ -15,8 +15,9 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from sqlalchemy import select +from sqlalchemy import select, tuple_ +from app.core.pagination import DEFAULT_PAGE_SIZE from app.models.ledger.journal_entry import JournalEntry, JournalEntryType from app.models.ledger.posting import Posting @@ -37,22 +38,42 @@ class HistoryEntry: amount_money: Money | None quantity_units: Units | None memo: str | None + posting_id: uuid.UUID + """Not surfaced in `HistoryEntryResponse` -- exists only as the cursor's third sort-key + component (S12 §8's `(effective_date, recorded_at, posting_id)`); the cursor is opaque, so a + field absent from the response body is fine there.""" class HistoryService: def __init__(self, uow: ValuationUnitOfWork) -> None: self._uow = uow - def history(self, customer_id: uuid.UUID, *, as_of: Watermark) -> list[HistoryEntry]: - statement = ( - select(JournalEntry, Posting) - .join(Posting, Posting.journal_entry_id == JournalEntry.id) - .where( - Posting.customer_id == customer_id, - JournalEntry.recorded_at <= as_of.cutoff, - ) - .order_by(JournalEntry.effective_date.desc(), JournalEntry.recorded_at.desc()) + def history( + self, + customer_id: uuid.UUID, + *, + as_of: Watermark, + limit: int = DEFAULT_PAGE_SIZE, + after: tuple[date, datetime, uuid.UUID] | None = None, + ) -> list[HistoryEntry]: + """Keyset-paginated on `(effective_date, recorded_at, posting_id)` DESC -- `posting_id` + breaks ties between a journal entry's own legs, which otherwise share both dates. Fetches + `limit + 1` rows; the caller applies `app.core.pagination.paginate()`.""" + statement = select(JournalEntry, Posting).join( + Posting, Posting.journal_entry_id == JournalEntry.id + ).where( + Posting.customer_id == customer_id, + JournalEntry.recorded_at <= as_of.cutoff, ) + if after is not None: + after_effective_date, after_recorded_at, after_posting_id = after + statement = statement.where( + tuple_(JournalEntry.effective_date, JournalEntry.recorded_at, Posting.id) + < (after_effective_date, after_recorded_at, after_posting_id) + ) + statement = statement.order_by( + JournalEntry.effective_date.desc(), JournalEntry.recorded_at.desc(), Posting.id.desc() + ).limit(limit + 1) rows = self._uow.session.execute(statement).all() return [ HistoryEntry( @@ -62,6 +83,7 @@ def history(self, customer_id: uuid.UUID, *, as_of: Watermark) -> list[HistoryEn amount_money=posting.amount_money, quantity_units=posting.quantity_units, memo=entry.memo, + posting_id=posting.id, ) for entry, posting in rows ] diff --git a/backend/app/views/funding.py b/backend/app/views/funding.py index 0ae0ed5..f84f1d6 100644 --- a/backend/app/views/funding.py +++ b/backend/app/views/funding.py @@ -59,6 +59,7 @@ class FundingHistoryResponse(BaseModel): """`GET /api/v1/funding/history` -- deposits and withdrawals, most-recent-first.""" entries: list[FundingHistoryEntryResponse] + next_cursor: str | None class LinkTokenResponse(BaseModel): diff --git a/backend/app/views/lots.py b/backend/app/views/lots.py index f899d39..1c1951a 100644 --- a/backend/app/views/lots.py +++ b/backend/app/views/lots.py @@ -59,6 +59,7 @@ class LotResponse(BaseModel): class LotsListResponse(BaseModel): lots: list[LotResponse] + next_cursor: str | None __all__ = ["LotConsumptionResponse", "LotResponse", "LotsListResponse"] diff --git a/backend/app/views/orders.py b/backend/app/views/orders.py index 9d0d743..91c31b8 100644 --- a/backend/app/views/orders.py +++ b/backend/app/views/orders.py @@ -86,6 +86,7 @@ class OrderDetailResponse(BaseModel): class OrderListResponse(BaseModel): orders: list[OrderResponse] + next_cursor: str | None __all__ = [ diff --git a/backend/app/views/statements.py b/backend/app/views/statements.py index 76ae9f5..c1c7767 100644 --- a/backend/app/views/statements.py +++ b/backend/app/views/statements.py @@ -36,6 +36,7 @@ def _serialize_twr(self, value: Decimal) -> str: class StatementsListResponse(BaseModel): statements: list[StatementSummaryResponse] + next_cursor: str | None class StatementDetailResponse(BaseModel): diff --git a/backend/app/views/valuation.py b/backend/app/views/valuation.py index d4a1823..218e2c5 100644 --- a/backend/app/views/valuation.py +++ b/backend/app/views/valuation.py @@ -58,6 +58,7 @@ class HistoryResponse(BaseModel): """`GET /api/v1/valuation/history` (S4 §7, FR-18) — live by default (ADR 6).""" entries: list[HistoryEntryResponse] + next_cursor: str | None __all__ = ["BalanceResponse", "HistoryEntryResponse", "HistoryResponse", "ReturnsResponse"] diff --git a/backend/tests/api/test_funding.py b/backend/tests/api/test_funding.py index 6e5288d..1cb695c 100644 --- a/backend/tests/api/test_funding.py +++ b/backend/tests/api/test_funding.py @@ -466,7 +466,7 @@ def test_get_funding_history_is_empty_for_a_new_customer( response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}") assert response.status_code == 200 - assert response.get_json() == {"entries": []} + assert response.get_json() == {"entries": [], "next_cursor": None} def test_get_funding_history_shows_a_new_deposit_as_pending_with_its_expected_settlement_date( @@ -632,7 +632,7 @@ def test_get_funding_history_excludes_another_customers_activity( response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}") - assert response.get_json() == {"entries": []} + assert response.get_json() == {"entries": [], "next_cursor": None} def test_get_funding_history_is_throttled(api_client: FlaskClient, db_committing) -> None: @@ -647,3 +647,67 @@ def test_get_funding_history_is_throttled(api_client: FlaskClient, db_committing assert last_response is not None assert last_response.status_code == 429 assert "Retry-After" in last_response.headers + + +# --- pagination (S12 §8) ---------------------------------------------------------------------- + + +def test_get_funding_history_rejects_limit_over_max( + api_client: FlaskClient, db_committing +) -> None: + customer_id, csrf_token = _register_and_login(api_client) + _link_bank(api_client, customer_id=customer_id, csrf_token=csrf_token) + _approve_customer(db_committing, customer_id) + + response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}&limit=201") + + assert response.status_code == 422 + + +def test_get_funding_history_rejects_a_malformed_cursor( + api_client: FlaskClient, db_committing +) -> None: + customer_id, csrf_token = _register_and_login(api_client) + _link_bank(api_client, customer_id=customer_id, csrf_token=csrf_token) + _approve_customer(db_committing, customer_id) + + response = api_client.get( + f"/api/v1/funding/history?customer_id={customer_id}&cursor=not-a-real-cursor" + ) + + assert response.status_code == 422 + + +def test_get_funding_history_pages_through_with_no_duplicate_or_skip( + api_client: FlaskClient, db_committing +) -> None: + customer_id, csrf_token = _register_and_login(api_client) + _link_bank(api_client, customer_id=customer_id, csrf_token=csrf_token) + _approve_customer(db_committing, customer_id) + for amount in ("100.00", "200.00", "300.00"): + api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": amount}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + full = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}").get_json() + assert len(full["entries"]) == 3 + assert full["next_cursor"] is None + + first_page = api_client.get( + f"/api/v1/funding/history?customer_id={customer_id}&limit=2" + ).get_json() + assert [e["journal_entry_id"] for e in first_page["entries"]] == [ + e["journal_entry_id"] for e in full["entries"][:2] + ] + assert first_page["next_cursor"] is not None + + second_page = api_client.get( + f"/api/v1/funding/history?customer_id={customer_id}&limit=2" + f"&cursor={first_page['next_cursor']}" + ).get_json() + assert [e["journal_entry_id"] for e in second_page["entries"]] == [ + e["journal_entry_id"] for e in full["entries"][2:] + ] + assert second_page["next_cursor"] is None diff --git a/backend/tests/api/test_lots.py b/backend/tests/api/test_lots.py index 2e2f742..1109a66 100644 --- a/backend/tests/api/test_lots.py +++ b/backend/tests/api/test_lots.py @@ -626,3 +626,60 @@ def test_is_throttled(api_client: FlaskClient) -> None: assert last_response is not None assert last_response.status_code == 429 assert "Retry-After" in last_response.headers + + +# --- pagination (S12 §8) ---------------------------------------------------------------------- + + +def test_list_lots_rejects_limit_over_max(api_client: FlaskClient) -> None: + _register_and_login(api_client) + + response = api_client.get("/api/v1/lots?limit=201") + + assert response.status_code == 422 + + +def test_list_lots_rejects_a_malformed_cursor(api_client: FlaskClient) -> None: + _register_and_login(api_client) + + response = api_client.get("/api/v1/lots?cursor=not-a-real-cursor") + + assert response.status_code == 422 + + +def test_list_lots_pages_through_with_no_duplicate_or_skip( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id, _ = _register_and_login(api_client) + session = Session(bind=owner_engine, expire_on_commit=False) + try: + for index, symbol in enumerate(("PAGEA", "PAGEB", "PAGEC")): + security_id = _seed_security(session, symbol=symbol) + _seed_open_lot( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + execution_id=f"buy-page-{index}", + quantity_opened=Units("10"), + quantity_remaining=Units("10"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.00"), + acquired_at=date(2026, 1, index + 1), + ) + session.commit() + finally: + session.close() + + full = api_client.get("/api/v1/lots").get_json() + assert len(full["lots"]) == 3 + assert full["next_cursor"] is None + + first_page = api_client.get("/api/v1/lots?limit=2").get_json() + assert [lot["id"] for lot in first_page["lots"]] == [lot["id"] for lot in full["lots"][:2]] + assert first_page["next_cursor"] is not None + + second_page = api_client.get( + f"/api/v1/lots?limit=2&cursor={first_page['next_cursor']}" + ).get_json() + assert [lot["id"] for lot in second_page["lots"]] == [lot["id"] for lot in full["lots"][2:]] + assert second_page["next_cursor"] is None diff --git a/backend/tests/api/test_orders.py b/backend/tests/api/test_orders.py index a908ec7..3db8f02 100644 --- a/backend/tests/api/test_orders.py +++ b/backend/tests/api/test_orders.py @@ -715,6 +715,100 @@ def test_get_order_with_no_session_is_rejected(api_client: FlaskClient) -> None: assert response.get_json()["code"] == "unauthenticated" +# --- read: pagination (S12 §8) --------------------------------------------------------------- + + +def test_list_orders_rejects_limit_over_max( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, _ = _authed_client(api_client, owner_engine) + + response = client.get("/api/v1/orders?limit=201") + + assert response.status_code == 422 + + +def test_list_orders_rejects_a_malformed_cursor( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, _ = _authed_client(api_client, owner_engine) + + response = client.get("/api/v1/orders?cursor=not-a-real-cursor") + + assert response.status_code == 422 + + +def test_list_orders_pages_through_with_no_duplicate_or_skip( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + for _ in range(3): + client.post( + "/api/v1/orders", + json=_create_payload(), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + full = client.get("/api/v1/orders").get_json() + assert len(full["orders"]) == 3 + assert full["next_cursor"] is None + + first_page = client.get("/api/v1/orders?limit=2").get_json() + assert [o["id"] for o in first_page["orders"]] == [o["id"] for o in full["orders"][:2]] + assert first_page["next_cursor"] is not None + + second_page = client.get( + f"/api/v1/orders?limit=2&cursor={first_page['next_cursor']}" + ).get_json() + assert [o["id"] for o in second_page["orders"]] == [o["id"] for o in full["orders"][2:]] + assert second_page["next_cursor"] is None + + +def test_list_orders_tiebreaks_stably_when_created_at_ties( + api_client: FlaskClient, owner_engine: Engine +) -> None: + """S12 §8's brief: today's ordering has no `id` tiebreak, so two orders sharing an identical + `created_at` would otherwise page unstably. Forces the tie directly, since two real requests + are not guaranteed to collide.""" + client, csrf_token = _authed_client(api_client, owner_engine) + first_id = uuid.UUID( + client.post( + "/api/v1/orders", + json=_create_payload(), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ).get_json()["id"] + ) + second_id = uuid.UUID( + client.post( + "/api/v1/orders", + json=_create_payload(), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ).get_json()["id"] + ) + tied_at = datetime(2026, 1, 1, tzinfo=UTC) + session = Session(bind=owner_engine, expire_on_commit=False) + try: + for order_id in (first_id, second_id): + order = session.get(Order, order_id) + assert order is not None + order.created_at = tied_at + session.commit() + finally: + session.close() + + expected_order = sorted([first_id, second_id], reverse=True) # id DESC breaks the tie + + first_page = client.get("/api/v1/orders?limit=1").get_json() + assert first_page["orders"][0]["id"] == str(expected_order[0]) + assert first_page["next_cursor"] is not None + + second_page = client.get( + f"/api/v1/orders?limit=1&cursor={first_page['next_cursor']}" + ).get_json() + assert second_page["orders"][0]["id"] == str(expected_order[1]) + assert second_page["next_cursor"] is None + + # --- read: fill timeline (S3 §6 order-detail depth) ------------------------------------------ diff --git a/backend/tests/api/test_statements.py b/backend/tests/api/test_statements.py new file mode 100644 index 0000000..de72908 --- /dev/null +++ b/backend/tests/api/test_statements.py @@ -0,0 +1,176 @@ +"""`GET /api/v1/statements` (S6 §8, FR-25/26) via the Flask test client: happy path, authn, and +cursor pagination (S12 §8) -- `limit`/`cursor` validation and a no-duplicate/no-skip page walk. +`GET /` and `//export` are covered by `test_statements_export.py`. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from datetime import UTC, date, datetime +from decimal import Decimal + +import pytest +from flask.testing import FlaskClient +from sqlalchemy import Engine, text +from sqlalchemy.orm import Session + +from app.core.money import Money +from app.models.restatement.published_snapshot import PublishedSnapshot + +CUSTOMER_EMAIL = "statements-customer@trueup.example" +OTHER_EMAIL = "statements-other@trueup.example" +PASSWORD = "correct-horse-battery" + +STATEMENTS_TABLES = [PublishedSnapshot.__table__] + + +@pytest.fixture(autouse=True) +def _statements_tables(owner_engine: Engine) -> Iterator[None]: + for table in STATEMENTS_TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield None + with owner_engine.begin() as connection: + for table in reversed(STATEMENTS_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE')) + + +def _register_and_login( + client: FlaskClient, *, email: str = CUSTOMER_EMAIL +) -> tuple[str, str]: + register_response = client.post( + "/api/v1/auth/register", json={"email": email, "password": PASSWORD} + ) + assert register_response.status_code == 201 + customer_id = register_response.get_json()["id"] + + login_response = client.post("/api/v1/auth/login", json={"email": email, "password": PASSWORD}) + assert login_response.status_code == 200 + csrf_token = login_response.get_json()["csrf_token"] + return customer_id, csrf_token + + +def _publish_snapshot( + owner_engine: Engine, + customer_id: uuid.UUID, + *, + period_start: date, + period_end: date, +) -> uuid.UUID: + session = Session(bind=owner_engine, expire_on_commit=False) + try: + snapshot = PublishedSnapshot( + customer_id=customer_id, + period_start=period_start, + period_end=period_end, + publish_watermark=datetime(period_end.year, period_end.month, 28, tzinfo=UTC), + twr=Decimal("0.0100000000"), + balance=Money("5000.00"), + holdings_json={}, + published_at=datetime(period_end.year, period_end.month, 28, tzinfo=UTC), + ) + session.add(snapshot) + session.commit() + return snapshot.id + finally: + session.close() + + +def test_requires_authentication(api_client: FlaskClient) -> None: + response = api_client.get("/api/v1/statements") + + assert response.status_code == 401 + + +def test_lists_published_statements_newest_period_first( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id, _ = _register_and_login(api_client) + _publish_snapshot( + owner_engine, uuid.UUID(customer_id), + period_start=date(2026, 1, 1), period_end=date(2026, 1, 31), + ) + _publish_snapshot( + owner_engine, uuid.UUID(customer_id), + period_start=date(2026, 2, 1), period_end=date(2026, 2, 28), + ) + + response = api_client.get("/api/v1/statements") + + assert response.status_code == 200 + body = response.get_json() + assert [s["period_start"] for s in body["statements"]] == ["2026-02-01", "2026-01-01"] + assert body["next_cursor"] is None + + +def test_a_customer_never_sees_another_customers_statements( + api_client: FlaskClient, owner_engine: Engine +) -> None: + other_id, other_csrf = _register_and_login(api_client, email=OTHER_EMAIL) + _publish_snapshot( + owner_engine, uuid.UUID(other_id), + period_start=date(2026, 1, 1), period_end=date(2026, 1, 31), + ) + api_client.post("/api/v1/auth/logout", headers={"X-CSRFToken": other_csrf}) + _register_and_login(api_client) + + response = api_client.get("/api/v1/statements") + + assert response.status_code == 200 + assert response.get_json() == {"statements": [], "next_cursor": None} + + +# --- pagination (S12 §8) ------------------------------------------------------------------------- + + +def test_rejects_limit_over_max(api_client: FlaskClient) -> None: + _register_and_login(api_client) + + response = api_client.get("/api/v1/statements?limit=201") + + assert response.status_code == 422 + + +def test_rejects_a_malformed_cursor(api_client: FlaskClient) -> None: + _register_and_login(api_client) + + response = api_client.get("/api/v1/statements?cursor=not-a-real-cursor") + + assert response.status_code == 422 + + +def test_pages_through_with_no_duplicate_or_skip( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id, _ = _register_and_login(api_client) + for month in (1, 2, 3): + _publish_snapshot( + owner_engine, uuid.UUID(customer_id), + period_start=date(2026, month, 1), period_end=date(2026, month, 28), + ) + + full = api_client.get("/api/v1/statements").get_json() + assert len(full["statements"]) == 3 + assert full["next_cursor"] is None + + first_page = api_client.get("/api/v1/statements?limit=2").get_json() + assert first_page["statements"] == full["statements"][:2] + assert first_page["next_cursor"] is not None + + second_page = api_client.get( + f"/api/v1/statements?limit=2&cursor={first_page['next_cursor']}" + ).get_json() + assert second_page["statements"] == full["statements"][2:] + assert second_page["next_cursor"] is None + + +def test_is_throttled(api_client: FlaskClient) -> None: + _register_and_login(api_client) + + last_response = None + for _ in range(61): + last_response = api_client.get("/api/v1/statements") + + assert last_response is not None + assert last_response.status_code == 429 + assert "Retry-After" in last_response.headers diff --git a/backend/tests/api/test_valuation_api.py b/backend/tests/api/test_valuation_api.py index 69bf802..6062ae0 100644 --- a/backend/tests/api/test_valuation_api.py +++ b/backend/tests/api/test_valuation_api.py @@ -132,6 +132,48 @@ def _deposit(owner_engine: Engine, customer_id: uuid.UUID, *, amount: Money, on: session.close() +def _deposit_using_existing_accounts( + owner_engine: Engine, customer_id: uuid.UUID, *, amount: Money, on: date +) -> None: + """Like `_deposit`, but assumes `_deposit` already ran once for `customer_id` -- reuses its + cash/equity accounts instead of creating a second `CustomerCashLock` row (its `customer_id` + is a primary key, so a second insert would violate it).""" + session = Session(bind=owner_engine, expire_on_commit=False) + try: + cash = ( + session.query(Account) + .filter_by(customer_id=customer_id, role=AccountRole.CASH) + .one() + ) + equity = ( + session.query(Account) + .filter_by(customer_id=customer_id, role=AccountRole.CUSTOMER_EQUITY) + .one() + ) + + event = InboundEvent( + source=InboundEventSource.CUSTODIAN_FILE, + source_event_id=str(uuid.uuid4()), + payload={}, + signature_verified=True, + ) + session.add(event) + session.flush() + + PostingService(_LedgerLikeUow(session)).post( + entry_type=JournalEntryType.DEPOSIT, + effective_date=on, + source_event_id=event.id, + legs=[ + PostingLeg(account_id=cash.id, amount_money=amount), + PostingLeg(account_id=equity.id, amount_money=-amount), + ], + ) + session.commit() + finally: + session.close() + + # --- balance ------------------------------------------------------------------------------- @@ -305,3 +347,53 @@ def test_history_happy_path_includes_the_deposit( assert all(entry["entry_type"] == "deposit" for entry in entries) amounts = {entry["amount_money"] for entry in entries} assert amounts == {"400.0000", "-400.0000"} + assert response.get_json()["next_cursor"] is None + + +# --- history: pagination (S12 §8) ------------------------------------------------------------- + + +def test_history_rejects_limit_over_max(api_client: FlaskClient) -> None: + _register(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + _login(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + + response = api_client.get("/api/v1/valuation/history?limit=201") + + assert response.status_code == 422 + + +def test_history_rejects_a_malformed_cursor(api_client: FlaskClient) -> None: + _register(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + _login(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + + response = api_client.get("/api/v1/valuation/history?cursor=not-a-real-cursor") + + assert response.status_code == 422 + + +def test_history_pages_through_with_no_duplicate_or_skip( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id = _register(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + _login(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + _deposit(owner_engine, customer_id, amount=Money("100.00"), on=date(2026, 9, 1)) + _deposit_using_existing_accounts( + owner_engine, customer_id, amount=Money("200.00"), on=date(2026, 9, 2) + ) + _deposit_using_existing_accounts( + owner_engine, customer_id, amount=Money("300.00"), on=date(2026, 9, 3) + ) + + full = api_client.get("/api/v1/valuation/history").get_json() + assert len(full["entries"]) == 6 # 2 postings per deposit x 3 deposits + assert full["next_cursor"] is None + + first_page = api_client.get("/api/v1/valuation/history?limit=4").get_json() + assert first_page["entries"] == full["entries"][:4] + assert first_page["next_cursor"] is not None + + second_page = api_client.get( + f"/api/v1/valuation/history?limit=4&cursor={first_page['next_cursor']}" + ).get_json() + assert second_page["entries"] == full["entries"][4:] + assert second_page["next_cursor"] is None diff --git a/backend/tests/integration/test_funding_history_service.py b/backend/tests/integration/test_funding_history_service.py index eb29acf..7e3b06a 100644 --- a/backend/tests/integration/test_funding_history_service.py +++ b/backend/tests/integration/test_funding_history_service.py @@ -6,7 +6,7 @@ import base64 import uuid from collections.abc import Iterator -from datetime import UTC, datetime +from datetime import UTC, date, datetime, timedelta import pytest from sqlalchemy import Engine, text @@ -14,6 +14,7 @@ from app.config import Settings from app.core.crypto import reset_cipher, set_cipher from app.core.money import Money +from app.core.pagination import decode_cursor, paginate from app.core.uow import SessionRole from app.core.watermark import Watermark from app.extensions import DbRole, dispose_engines, init_engines @@ -28,11 +29,11 @@ SettlementObligation, SettlementObligationStatus, ) -from app.models.ops.inbound_event import InboundEvent +from app.models.ops.inbound_event import InboundEvent, InboundEventSource from app.models.orders.approval_hold import ApprovalHold from app.models.orders.order import Order from app.services.identity.deposit_service import DepositService -from app.services.identity.funding_history_service import FundingHistoryService +from app.services.identity.funding_history_service import FundingHistoryEntry, FundingHistoryService from app.services.identity.funding_uow import FundingUnitOfWork from app.services.identity.withdrawal_service import WithdrawalService from app.services.ledger.cash_policy_service import CashPolicyService @@ -241,3 +242,136 @@ def test_history_includes_a_withdrawal_with_a_negative_amount(db_committing) -> withdrawal = entries[JournalEntryType.WITHDRAWAL] assert withdrawal.amount == Money("-100.00") assert withdrawal.settlement_status is None + + +# --- pagination (S12 §8) ------------------------------------------------------------------------ + + +def _seed_funding_entry( + db_committing, + *, + cash_account_id: uuid.UUID, + equity_account_id: uuid.UUID, + effective_date: date, + recorded_at: datetime, + amount: Money, + entry_id: uuid.UUID | None = None, +) -> JournalEntry: + """A deposit's two legs, built directly rather than through `DepositService` so the caller can + pin `effective_date`/`recorded_at`/`id` precisely (pagination-stability needs exact ties).""" + event_row = InboundEvent( + source=InboundEventSource.CUSTODIAN_FILE, + source_event_id=str(uuid.uuid4()), + payload={}, + signature_verified=True, + ) + db_committing.add(event_row) + db_committing.flush() + + entry = JournalEntry( + id=entry_id if entry_id is not None else uuid.uuid4(), + entry_type=JournalEntryType.DEPOSIT, + effective_date=effective_date, + recorded_at=recorded_at, + source_event_id=event_row.id, + ) + db_committing.add(entry) + db_committing.flush() + db_committing.add_all( + [ + Posting(journal_entry_id=entry.id, account_id=cash_account_id, amount_money=amount), + Posting( + journal_entry_id=entry.id, account_id=equity_account_id, amount_money=-amount + ), + ] + ) + db_committing.commit() + return entry + + +def _cursor_key(entry: FundingHistoryEntry) -> tuple[str, str, str]: + return ( + entry.effective_date.isoformat(), + entry.recorded_at.isoformat(), + str(entry.journal_entry_id), + ) + + +def _decode_after(raw_cursor: str) -> tuple[date, datetime, uuid.UUID]: + decoded = decode_cursor(raw_cursor) + return ( + date.fromisoformat(decoded[0]), # type: ignore[arg-type] + datetime.fromisoformat(decoded[1]), # type: ignore[arg-type] + uuid.UUID(decoded[2]), # type: ignore[arg-type] + ) + + +def test_history_pagination_is_stable_under_a_mid_walk_insert(db_committing) -> None: + customer_id = _approved_customer(db_committing) + cash = Account.create(AccountRole.CASH, customer_id=customer_id) + equity = Account.create(AccountRole.CUSTOMER_EQUITY, customer_id=customer_id) + db_committing.add_all([cash, equity]) + db_committing.commit() + + base_date = date(2026, 9, 1) + base_recorded = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + seeded = [ + _seed_funding_entry( + db_committing, + cash_account_id=cash.id, + equity_account_id=equity.id, + effective_date=base_date + timedelta(days=i), + recorded_at=base_recorded + timedelta(days=i), + amount=Money(f"{100 * (i + 1)}.00"), + ) + for i in range(5) + ] + # DESC order: seeded[4] (latest dates) is newest ... seeded[0] is oldest. + newest_first = list(reversed(seeded)) + + def _uow() -> FundingUnitOfWork: + return FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) + + with _uow() as uow: + rows = FundingHistoryService(uow).history(customer_id, as_of=Watermark.live(), limit=2) + page1 = paginate(rows, limit=2, cursor_key=_cursor_key) + assert [e.journal_entry_id for e in page1.items] == [newest_first[0].id, newest_first[1].id] + assert page1.next_cursor is not None + + # An entry tied on both `effective_date` and `recorded_at` with newest_first[1], given a + # deliberately-maximal `id` (guaranteed greater than any uuid4) so the DESC tiebreak places it + # strictly before newest_first[1] -- into page 1's already-yielded position -- after page 1 + # has already been fetched. + mid_insert = _seed_funding_entry( + db_committing, + cash_account_id=cash.id, + equity_account_id=equity.id, + effective_date=newest_first[1].effective_date, + recorded_at=newest_first[1].recorded_at, + amount=Money("999.00"), + entry_id=uuid.UUID(int=2**128 - 1), + ) + + after = _decode_after(page1.next_cursor) + with _uow() as uow: + rows = FundingHistoryService(uow).history( + customer_id, as_of=Watermark.live(), limit=2, after=after + ) + page2 = paginate(rows, limit=2, cursor_key=_cursor_key) + assert [e.journal_entry_id for e in page2.items] == [newest_first[2].id, newest_first[3].id] + assert page2.next_cursor is not None + + after = _decode_after(page2.next_cursor) + with _uow() as uow: + rows = FundingHistoryService(uow).history( + customer_id, as_of=Watermark.live(), limit=2, after=after + ) + page3 = paginate(rows, limit=2, cursor_key=_cursor_key) + assert [e.journal_entry_id for e in page3.items] == [newest_first[4].id] + assert page3.next_cursor is None + + walked_ids = [e.journal_entry_id for page in (page1, page2, page3) for e in page.items] + assert walked_ids == [e.id for e in newest_first] # every seeded row exactly once, in order + assert mid_insert.id not in walked_ids # the mid-walk insert never leaks into the walk diff --git a/backend/tests/integration/test_order_repository_pagination.py b/backend/tests/integration/test_order_repository_pagination.py new file mode 100644 index 0000000..6410371 --- /dev/null +++ b/backend/tests/integration/test_order_repository_pagination.py @@ -0,0 +1,107 @@ +"""`OrderRepository.list_for_customer` (S12 §8) — keyset-pagination stability against real +Postgres: paging with `limit=2` across 5 seeded orders survives a new order inserted mid-walk +into an already-yielded page's position, with no repeated or skipped row.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import text + +from app.core.db import DbRole +from app.core.money import Units +from app.core.pagination import decode_cursor, paginate +from app.core.uow import SessionRole +from app.models.orders.order import Order, OrderSide, OrderStatus, derive_client_order_id +from app.services.orders.uow import OrdersUnitOfWork +from tests.integration.conftest import LEDGER_TABLES, insert_customer + +ORDER_TABLES = [*LEDGER_TABLES, Order.__table__] + + +@pytest.fixture +def order_tables(owner_engine): + for table in ORDER_TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield + with owner_engine.begin() as connection: + for table in reversed(ORDER_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE')) + + +pytestmark = pytest.mark.usefixtures("order_tables") + + +def _order(customer_id: uuid.UUID, *, created_at: datetime) -> Order: + order_id = uuid.uuid4() + return Order( + id=order_id, + customer_id=customer_id, + security_id=uuid.uuid4(), + side=OrderSide.BUY, + quantity_requested=Units("10"), + status=OrderStatus.APPROVED, + filled_quantity=Units("0"), + client_order_id=derive_client_order_id(order_id), + created_at=created_at, + ) + + +def _cursor_key(order: Order) -> tuple[str, str]: + return (order.created_at.isoformat(), str(order.id)) + + +def _decode_after(raw_cursor: str) -> tuple[datetime, uuid.UUID]: + decoded = decode_cursor(raw_cursor) + return datetime.fromisoformat(decoded[0]), uuid.UUID(decoded[1]) # type: ignore[arg-type] + + +def test_list_for_customer_pagination_is_stable_under_a_mid_walk_insert(db_committing) -> None: + customer_id = insert_customer(db_committing) + base = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) + # DESC order: seeded[0] (base) is newest ... seeded[4] (base - 4min) is oldest. + seeded = [_order(customer_id, created_at=base - timedelta(minutes=i)) for i in range(5)] + db_committing.add_all(seeded) + db_committing.commit() + + def _uow() -> OrdersUnitOfWork: + return OrdersUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) + + # `.items` are attached to each `with` block's own session; ids are extracted before it + # closes (closing expires -- and detaches access to -- the loaded ORM instances). + with _uow() as uow: + rows = uow.orders.list_for_customer(customer_id, limit=2) + page1 = paginate(rows, limit=2, cursor_key=_cursor_key) + page1_ids = [o.id for o in page1.items] + assert page1_ids == [seeded[0].id, seeded[1].id] + assert page1.next_cursor is not None + + # Insert a new order whose `created_at` falls strictly between seeded[0] and seeded[1] -- + # into page 1's already-yielded position -- after page 1 has already been fetched. + mid_insert = _order(customer_id, created_at=base - timedelta(seconds=30)) + db_committing.add(mid_insert) + db_committing.commit() + + after = _decode_after(page1.next_cursor) + with _uow() as uow: + rows = uow.orders.list_for_customer(customer_id, limit=2, after=after) + page2 = paginate(rows, limit=2, cursor_key=_cursor_key) + page2_ids = [o.id for o in page2.items] + assert page2_ids == [seeded[2].id, seeded[3].id] + assert page2.next_cursor is not None + + after = _decode_after(page2.next_cursor) + with _uow() as uow: + rows = uow.orders.list_for_customer(customer_id, limit=2, after=after) + page3 = paginate(rows, limit=2, cursor_key=_cursor_key) + page3_ids = [o.id for o in page3.items] + assert page3_ids == [seeded[4].id] + assert page3.next_cursor is None + + walked_ids = [*page1_ids, *page2_ids, *page3_ids] + assert walked_ids == [o.id for o in seeded] # every seeded row exactly once, in order + assert mid_insert.id not in walked_ids # the mid-walk insert never leaks into the walk diff --git a/backend/tests/integration/test_published_snapshot_repository_pagination.py b/backend/tests/integration/test_published_snapshot_repository_pagination.py new file mode 100644 index 0000000..ed299dc --- /dev/null +++ b/backend/tests/integration/test_published_snapshot_repository_pagination.py @@ -0,0 +1,129 @@ +"""`PublishedSnapshotRepository.list_for_customer` (S6 §8, S12 §8) — keyset-pagination stability +against real Postgres: paging with `limit=2` across 5 seeded snapshots survives a new snapshot +inserted mid-walk into an already-yielded page's position, with no repeated or skipped row. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import Engine, text + +from app.core.db import DbRole +from app.core.money import Money +from app.core.pagination import decode_cursor, paginate +from app.core.uow import SessionRole +from app.models.identity.customer import Customer +from app.models.restatement.published_snapshot import PublishedSnapshot +from app.services.restatement.uow import RestatementUnitOfWork +from tests.integration.conftest import insert_customer + +_TABLES = [Customer.__table__, PublishedSnapshot.__table__] + + +@pytest.fixture +def _tables(owner_engine: Engine) -> Iterator[None]: + for table in _TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield None + with owner_engine.begin() as connection: + for table in reversed(_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE')) + + +pytestmark = pytest.mark.usefixtures("_tables") + + +def _seed_snapshot( + db_committing, + *, + customer_id: uuid.UUID, + period_start: date, + snapshot_id: uuid.UUID | None = None, +) -> PublishedSnapshot: + period_end = period_start + timedelta(days=27) + snapshot = PublishedSnapshot( + id=snapshot_id if snapshot_id is not None else uuid.uuid4(), + customer_id=customer_id, + period_start=period_start, + period_end=period_end, + publish_watermark=datetime.combine(period_end, datetime.min.time(), tzinfo=UTC), + twr=Decimal("0.0100000000"), + balance=Money("1000.00"), + holdings_json={}, + published_at=datetime.combine(period_end, datetime.min.time(), tzinfo=UTC), + ) + db_committing.add(snapshot) + db_committing.commit() + return snapshot + + +def _cursor_key(snapshot: PublishedSnapshot) -> tuple[str, str]: + return (snapshot.period_start.isoformat(), str(snapshot.id)) + + +def _decode_after(raw_cursor: str) -> tuple[date, uuid.UUID]: + decoded = decode_cursor(raw_cursor) + return date.fromisoformat(decoded[0]), uuid.UUID(decoded[1]) # type: ignore[arg-type] + + +def test_list_for_customer_pagination_is_stable_under_a_mid_walk_insert(db_committing) -> None: + customer_id = insert_customer(db_committing) + base = date(2026, 1, 1) + # DESC order: seeded[4] (latest period_start) is newest ... seeded[0] is oldest. + seeded = [ + _seed_snapshot( + db_committing, customer_id=customer_id, period_start=base + timedelta(days=31 * i) + ) + for i in range(5) + ] + newest_first = list(reversed(seeded)) + + def _uow() -> RestatementUnitOfWork: + return RestatementUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) + + # `.items` are attached to each `with` block's own session; ids are extracted before it + # closes (closing expires -- and detaches access to -- the loaded ORM instances). + with _uow() as uow: + rows = uow.published_snapshots.list_for_customer(customer_id, limit=2) + page1 = paginate(rows, limit=2, cursor_key=_cursor_key) + page1_ids = [s.id for s in page1.items] + assert page1_ids == [newest_first[0].id, newest_first[1].id] + assert page1.next_cursor is not None + + # A snapshot tied on `period_start` with newest_first[1], given a deliberately-maximal `id` + # (guaranteed greater than any uuid4) so the DESC tiebreak places it strictly before + # newest_first[1] -- into page 1's already-yielded position -- after page 1 has already been + # fetched. + mid_insert = _seed_snapshot( + db_committing, + customer_id=customer_id, + period_start=newest_first[1].period_start, + snapshot_id=uuid.UUID(int=2**128 - 1), + ) + + after = _decode_after(page1.next_cursor) + with _uow() as uow: + rows = uow.published_snapshots.list_for_customer(customer_id, limit=2, after=after) + page2 = paginate(rows, limit=2, cursor_key=_cursor_key) + page2_ids = [s.id for s in page2.items] + assert page2_ids == [newest_first[2].id, newest_first[3].id] + assert page2.next_cursor is not None + + after = _decode_after(page2.next_cursor) + with _uow() as uow: + rows = uow.published_snapshots.list_for_customer(customer_id, limit=2, after=after) + page3 = paginate(rows, limit=2, cursor_key=_cursor_key) + page3_ids = [s.id for s in page3.items] + assert page3_ids == [newest_first[4].id] + assert page3.next_cursor is None + + walked_ids = [*page1_ids, *page2_ids, *page3_ids] + assert walked_ids == [s.id for s in newest_first] # every seeded row exactly once, in order + assert mid_insert.id not in walked_ids # the mid-walk insert never leaks into the walk diff --git a/backend/tests/integration/test_tax_lot_repository_pagination.py b/backend/tests/integration/test_tax_lot_repository_pagination.py new file mode 100644 index 0000000..4803994 --- /dev/null +++ b/backend/tests/integration/test_tax_lot_repository_pagination.py @@ -0,0 +1,148 @@ +"""`TaxLotRepository.list_for_customer` (S12 §8) — keyset-pagination stability against real +Postgres: paging with `limit=2` across 5 seeded lots survives a new lot inserted mid-walk into an +already-yielded page's position, with no repeated or skipped row.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, date, datetime, timedelta + +import pytest +from sqlalchemy import text + +from app.core.db import DbRole +from app.core.money import Money, Units +from app.core.pagination import decode_cursor, paginate +from app.core.uow import SessionRole +from app.models.ledger.tax_lot import LotDesignation, TaxLot +from app.models.marketdata.security import Security, SecurityAssetClass +from app.models.orders.order import Order, OrderSide, OrderStatus, derive_client_order_id +from app.models.orders.order_event import OrderEvent, OrderEventType +from app.services.lots.uow import LotsUnitOfWork +from tests.integration.conftest import LEDGER_TABLES, insert_customer + +TAX_LOT_TABLES = [*LEDGER_TABLES, Security.__table__, Order.__table__, OrderEvent.__table__, + TaxLot.__table__] + + +@pytest.fixture +def tax_lot_tables(owner_engine): + for table in TAX_LOT_TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield + with owner_engine.begin() as connection: + for table in reversed(TAX_LOT_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE')) + + +pytestmark = pytest.mark.usefixtures("tax_lot_tables") + + +def _seed_lot(db_committing, *, customer_id: uuid.UUID, security_id: uuid.UUID, + acquired_at: date, lot_id: uuid.UUID | None = None) -> TaxLot: + execution_id = f"exec-{uuid.uuid4()}" + order_id = uuid.uuid4() + order = Order( + id=order_id, + customer_id=customer_id, + security_id=security_id, + side=OrderSide.BUY, + quantity_requested=Units("10"), + status=OrderStatus.FILLED, + filled_quantity=Units("10"), + client_order_id=derive_client_order_id(order_id), + ) + db_committing.add(order) + db_committing.flush() + db_committing.add( + OrderEvent( + order_id=order_id, seq=1, event_type=OrderEventType.FILL, + execution_id=execution_id, payload={}, + ) + ) + db_committing.flush() + + lot = TaxLot( + id=lot_id if lot_id is not None else uuid.uuid4(), + customer_id=customer_id, + security_id=security_id, + opening_fill_execution_id=execution_id, + quantity_opened=Units("10"), + quantity_remaining=Units("10"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.00"), + acquired_at=acquired_at, + designation=LotDesignation.UNSPECIFIED, + designation_window_closes_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + db_committing.add(lot) + db_committing.commit() + return lot + + +def _cursor_key(lot: TaxLot) -> tuple[str, str]: + return (lot.acquired_at.isoformat(), str(lot.id)) + + +def _decode_after(raw_cursor: str) -> tuple[date, uuid.UUID]: + decoded = decode_cursor(raw_cursor) + return date.fromisoformat(decoded[0]), uuid.UUID(decoded[1]) # type: ignore[arg-type] + + +def test_list_for_customer_pagination_is_stable_under_a_mid_walk_insert(db_committing) -> None: + customer_id = insert_customer(db_committing) + security = Security(symbol="PGSTB", name="Pagination Stability Co.", + asset_class=SecurityAssetClass.EQUITY) + db_committing.add(security) + db_committing.commit() + + base = date(2026, 1, 1) + # ASC order: seeded[0] (base) is oldest ... seeded[4] (base + 4 days) is newest. + seeded = [ + _seed_lot(db_committing, customer_id=customer_id, security_id=security.id, + acquired_at=base + timedelta(days=i)) + for i in range(5) + ] + + def _uow() -> LotsUnitOfWork: + return LotsUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) + + # `.items` are attached to each `with` block's own session; ids are extracted before it + # closes (closing expires -- and detaches access to -- the loaded ORM instances). + with _uow() as uow: + rows = uow.tax_lots.list_for_customer(customer_id, limit=2) + page1 = paginate(rows, limit=2, cursor_key=_cursor_key) + page1_ids = [lot.id for lot in page1.items] + assert page1_ids == [seeded[0].id, seeded[1].id] + assert page1.next_cursor is not None + + # A same-day lot ties on `acquired_at` alone; seed it exactly on seeded[1]'s date with a + # deliberately-minimal `id` (guaranteed less than any uuid4) so the `(acquired_at, id)` + # tiebreak places it strictly before seeded[1] -- into page 1's already-yielded position -- + # after page 1 has already been fetched. + mid_insert = _seed_lot( + db_committing, customer_id=customer_id, security_id=security.id, + acquired_at=seeded[1].acquired_at, lot_id=uuid.UUID(int=0), + ) + + after = _decode_after(page1.next_cursor) + with _uow() as uow: + rows = uow.tax_lots.list_for_customer(customer_id, limit=2, after=after) + page2 = paginate(rows, limit=2, cursor_key=_cursor_key) + page2_ids = [lot.id for lot in page2.items] + assert page2_ids == [seeded[2].id, seeded[3].id] + assert page2.next_cursor is not None + + after = _decode_after(page2.next_cursor) + with _uow() as uow: + rows = uow.tax_lots.list_for_customer(customer_id, limit=2, after=after) + page3 = paginate(rows, limit=2, cursor_key=_cursor_key) + page3_ids = [lot.id for lot in page3.items] + assert page3_ids == [seeded[4].id] + assert page3.next_cursor is None + + walked_ids = [*page1_ids, *page2_ids, *page3_ids] + assert walked_ids == [lot.id for lot in seeded] # every seeded row exactly once, in order + assert mid_insert.id not in walked_ids # the mid-walk insert never leaks into the walk diff --git a/backend/tests/integration/test_valuation_history_service_pagination.py b/backend/tests/integration/test_valuation_history_service_pagination.py new file mode 100644 index 0000000..d92097e --- /dev/null +++ b/backend/tests/integration/test_valuation_history_service_pagination.py @@ -0,0 +1,189 @@ +"""`HistoryService.history` (S4 §7, S12 §8) — keyset-pagination stability against real Postgres: +paging with `limit=4` across 5 seeded deposits (10 posting-leg rows) survives a new deposit +inserted mid-walk into an already-yielded page's position, with no repeated or skipped row. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from datetime import UTC, date, datetime, timedelta + +import pytest +from sqlalchemy import Engine, text + +from app.core.db import DbRole +from app.core.money import Money +from app.core.pagination import decode_cursor, paginate +from app.core.uow import SessionRole +from app.core.watermark import Watermark +from app.models.identity.customer import Customer +from app.models.ledger.account import Account, AccountRole +from app.models.ledger.journal_entry import JournalEntry, JournalEntryType +from app.models.ledger.posting import Posting +from app.models.ops.inbound_event import InboundEvent, InboundEventSource +from app.services.valuation.history_service import HistoryEntry, HistoryService +from app.services.valuation.uow import ValuationUnitOfWork +from tests.integration.conftest import insert_customer + +_TABLES = [ + Customer.__table__, + InboundEvent.__table__, + Account.__table__, + JournalEntry.__table__, + Posting.__table__, +] + + +@pytest.fixture +def _tables(owner_engine: Engine) -> Iterator[None]: + for table in _TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield None + with owner_engine.begin() as connection: + for table in reversed(_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE')) + + +pytestmark = pytest.mark.usefixtures("_tables") + + +def _seed_deposit( + db_committing, + *, + cash_account_id: uuid.UUID, + equity_account_id: uuid.UUID, + effective_date: date, + recorded_at: datetime, + amount: Money, + cash_posting_id: uuid.UUID | None = None, + equity_posting_id: uuid.UUID | None = None, +) -> JournalEntry: + event_row = InboundEvent( + source=InboundEventSource.CUSTODIAN_FILE, + source_event_id=str(uuid.uuid4()), + payload={}, + signature_verified=True, + ) + db_committing.add(event_row) + db_committing.flush() + + entry = JournalEntry( + entry_type=JournalEntryType.DEPOSIT, + effective_date=effective_date, + recorded_at=recorded_at, + source_event_id=event_row.id, + ) + db_committing.add(entry) + db_committing.flush() + db_committing.add_all( + [ + Posting( + id=cash_posting_id if cash_posting_id is not None else uuid.uuid4(), + journal_entry_id=entry.id, + account_id=cash_account_id, + amount_money=amount, + ), + Posting( + id=equity_posting_id if equity_posting_id is not None else uuid.uuid4(), + journal_entry_id=entry.id, + account_id=equity_account_id, + amount_money=-amount, + ), + ] + ) + db_committing.commit() + return entry + + +def _cursor_key(entry: HistoryEntry) -> tuple[str, str, str]: + return (entry.effective_date.isoformat(), entry.recorded_at.isoformat(), str(entry.posting_id)) + + +def _decode_after(raw_cursor: str) -> tuple[date, datetime, uuid.UUID]: + decoded = decode_cursor(raw_cursor) + return ( + date.fromisoformat(decoded[0]), # type: ignore[arg-type] + datetime.fromisoformat(decoded[1]), # type: ignore[arg-type] + uuid.UUID(decoded[2]), # type: ignore[arg-type] + ) + + +def test_history_pagination_is_stable_under_a_mid_walk_insert(db_committing) -> None: + customer_id = insert_customer(db_committing) + cash = Account.create(AccountRole.CASH, customer_id=customer_id) + equity = Account.create(AccountRole.CUSTOMER_EQUITY, customer_id=customer_id) + db_committing.add_all([cash, equity]) + db_committing.commit() + + base_date = date(2026, 9, 1) + base_recorded = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + # Deterministic intra-date tiebreak: within group i, the cash leg's id always exceeds the + # equity leg's, so DESC order within one date is always [cash, equity]. + for i in range(5): + _seed_deposit( + db_committing, + cash_account_id=cash.id, + equity_account_id=equity.id, + effective_date=base_date + timedelta(days=i), + recorded_at=base_recorded + timedelta(days=i), + amount=Money(f"{100 * (i + 1)}.00"), + cash_posting_id=uuid.UUID(int=2 * i + 2), + equity_posting_id=uuid.UUID(int=2 * i + 1), + ) + # DESC walk (newest date first, cash leg before equity leg within a date): + # [cash_4, equity_4, cash_3, equity_3, cash_2, equity_2, cash_1, equity_1, cash_0, equity_0] + expected_walk = [ + uuid.UUID(int=n) for i in reversed(range(5)) for n in (2 * i + 2, 2 * i + 1) + ] + + def _uow() -> ValuationUnitOfWork: + return ValuationUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) + + with _uow() as uow: + rows = HistoryService(uow).history(customer_id, as_of=Watermark.live(), limit=4) + page1 = paginate(rows, limit=4, cursor_key=_cursor_key) + assert [e.posting_id for e in page1.items] == expected_walk[:4] + assert page1.next_cursor is not None + + # A deposit tied on both dates with group i=3 (page 1's last-yielded date), given + # deliberately-maximal posting ids (guaranteed greater than any uuid4 used above) so the DESC + # tiebreak places both its legs strictly before group 3's own legs -- into page 1's + # already-yielded position -- after page 1 has already been fetched. + mid_insert_cash_id = uuid.UUID(int=2**128 - 1) + mid_insert_equity_id = uuid.UUID(int=2**128 - 2) + _seed_deposit( + db_committing, + cash_account_id=cash.id, + equity_account_id=equity.id, + effective_date=base_date + timedelta(days=3), + recorded_at=base_recorded + timedelta(days=3), + amount=Money("999.00"), + cash_posting_id=mid_insert_cash_id, + equity_posting_id=mid_insert_equity_id, + ) + mid_insert_posting_ids = {mid_insert_cash_id, mid_insert_equity_id} + + after = _decode_after(page1.next_cursor) + with _uow() as uow: + rows = HistoryService(uow).history( + customer_id, as_of=Watermark.live(), limit=4, after=after + ) + page2 = paginate(rows, limit=4, cursor_key=_cursor_key) + assert [e.posting_id for e in page2.items] == expected_walk[4:8] + assert page2.next_cursor is not None + + after = _decode_after(page2.next_cursor) + with _uow() as uow: + rows = HistoryService(uow).history( + customer_id, as_of=Watermark.live(), limit=4, after=after + ) + page3 = paginate(rows, limit=4, cursor_key=_cursor_key) + assert [e.posting_id for e in page3.items] == expected_walk[8:] + assert page3.next_cursor is None + + walked_ids = [e.posting_id for page in (page1, page2, page3) for e in page.items] + assert walked_ids == expected_walk # every seeded posting exactly once, in order + assert not (mid_insert_posting_ids & set(walked_ids)) # mid-walk insert never leaks in diff --git a/backend/tests/unit/test_pagination_cursor_keys.py b/backend/tests/unit/test_pagination_cursor_keys.py new file mode 100644 index 0000000..342cadb --- /dev/null +++ b/backend/tests/unit/test_pagination_cursor_keys.py @@ -0,0 +1,94 @@ +"""Each of the 5 paginated endpoints' cursor-key tuples (S12 §8) round-trips through +`encode_cursor`/`decode_cursor` with the correct arity and types. This is a contract test for the +shape each endpoint's own `_decode_*_cursor` helper depends on -- it does not exercise those +helpers directly (they live in `app/controllers/api/*.py`), only the shared primitive underneath. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, date, datetime + +from app.core.pagination import decode_cursor, encode_cursor + +# --- GET /orders: (created_at, id) DESC --------------------------------------------------------- + + +def test_orders_cursor_key_round_trips() -> None: + created_at = datetime(2026, 9, 4, 12, 0, tzinfo=UTC) + order_id = uuid.uuid4() + + cursor = encode_cursor(created_at.isoformat(), str(order_id)) + decoded = decode_cursor(cursor) + + assert len(decoded) == 2 + assert datetime.fromisoformat(decoded[0]) == created_at # type: ignore[arg-type] + assert uuid.UUID(decoded[1]) == order_id # type: ignore[arg-type] + + +# --- GET /lots: (acquired_at, id) ASC ------------------------------------------------------------ + + +def test_lots_cursor_key_round_trips() -> None: + acquired_at = date(2026, 1, 5) + lot_id = uuid.uuid4() + + cursor = encode_cursor(acquired_at.isoformat(), str(lot_id)) + decoded = decode_cursor(cursor) + + assert len(decoded) == 2 + assert date.fromisoformat(decoded[0]) == acquired_at # type: ignore[arg-type] + assert uuid.UUID(decoded[1]) == lot_id # type: ignore[arg-type] + + +# --- GET /funding/history: (effective_date, recorded_at, journal_entry_id) DESC ------------------ + + +def test_funding_history_cursor_key_round_trips() -> None: + effective_date = date(2026, 9, 1) + recorded_at = datetime(2026, 9, 1, 8, 30, tzinfo=UTC) + journal_entry_id = uuid.uuid4() + + cursor = encode_cursor( + effective_date.isoformat(), recorded_at.isoformat(), str(journal_entry_id) + ) + decoded = decode_cursor(cursor) + + assert len(decoded) == 3 + assert date.fromisoformat(decoded[0]) == effective_date # type: ignore[arg-type] + assert datetime.fromisoformat(decoded[1]) == recorded_at # type: ignore[arg-type] + assert uuid.UUID(decoded[2]) == journal_entry_id # type: ignore[arg-type] + + +# --- GET /valuation/history: (effective_date, recorded_at, posting_id) DESC ---------------------- + + +def test_valuation_history_cursor_key_round_trips() -> None: + """`posting_id` never appears in `HistoryEntryResponse` -- the cursor is opaque, so this only + proves the round-trip, not that the field is surfaced anywhere in the response body.""" + effective_date = date(2026, 9, 1) + recorded_at = datetime(2026, 9, 1, 8, 30, tzinfo=UTC) + posting_id = uuid.uuid4() + + cursor = encode_cursor(effective_date.isoformat(), recorded_at.isoformat(), str(posting_id)) + decoded = decode_cursor(cursor) + + assert len(decoded) == 3 + assert date.fromisoformat(decoded[0]) == effective_date # type: ignore[arg-type] + assert datetime.fromisoformat(decoded[1]) == recorded_at # type: ignore[arg-type] + assert uuid.UUID(decoded[2]) == posting_id # type: ignore[arg-type] + + +# --- GET /statements: (period_start, id) DESC ----------------------------------------------------- + + +def test_statements_cursor_key_round_trips() -> None: + period_start = date(2026, 8, 1) + snapshot_id = uuid.uuid4() + + cursor = encode_cursor(period_start.isoformat(), str(snapshot_id)) + decoded = decode_cursor(cursor) + + assert len(decoded) == 2 + assert date.fromisoformat(decoded[0]) == period_start # type: ignore[arg-type] + assert uuid.UUID(decoded[1]) == snapshot_id # type: ignore[arg-type] From 89f89b391d42128d492a223f7bd87246fed299fc Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Tue, 8 Sep 2026 22:14:05 -0700 Subject: [PATCH 05/14] fix(api): preserve republish-recency order in the statements cursor key The statements pagination cursor key was (period_start, id) DESC, but period_start is not unique per customer (FR-26: a period can be republished under multiple watermarks) and publish_watermark is what latest_for_period already uses to mean "most recent republish first." The id-only tiebreak silently discarded that order for any customer with more than one snapshot per period_start. Cursor key is now (period_start, publish_watermark, id) DESC, matching the 3-tuple shape already used for funding/history and valuation/history in this same change; id remains only as the final tamper-evident tiebreak. --- backend/app/controllers/api/statements.py | 25 ++++++++--- .../models/restatement/published_snapshot.py | 22 ++++++--- backend/tests/api/test_statements.py | 45 ++++++++++++++++++- ...ublished_snapshot_repository_pagination.py | 16 +++++-- .../tests/unit/test_pagination_cursor_keys.py | 16 +++++-- 5 files changed, 103 insertions(+), 21 deletions(-) diff --git a/backend/app/controllers/api/statements.py b/backend/app/controllers/api/statements.py index bbbb5cc..3f78263 100644 --- a/backend/app/controllers/api/statements.py +++ b/backend/app/controllers/api/statements.py @@ -66,12 +66,21 @@ def _parse_optional_limit(raw: str | None) -> int | None: raise ValidationError("limit must be an integer") from exc -def _decode_statement_cursor(raw: str) -> tuple[date, uuid.UUID]: +def _decode_statement_cursor(raw: str) -> tuple[date, datetime, uuid.UUID]: decoded = decode_cursor(raw) - if len(decoded) != 2 or not isinstance(decoded[0], str) or not isinstance(decoded[1], str): + if ( + len(decoded) != 3 + or not isinstance(decoded[0], str) + or not isinstance(decoded[1], str) + or not isinstance(decoded[2], str) + ): raise ValidationError("invalid pagination cursor") try: - return date.fromisoformat(decoded[0]), uuid.UUID(decoded[1]) + return ( + date.fromisoformat(decoded[0]), + datetime.fromisoformat(decoded[1]), + uuid.UUID(decoded[2]), + ) except ValueError as exc: raise ValidationError("invalid pagination cursor") from exc @@ -82,7 +91,7 @@ def _decode_statement_cursor(raw: str) -> tuple[date, uuid.UUID]: def list_statements() -> Any: customer_id = _resolve_customer_id() limit = normalize_limit(_parse_optional_limit(request.args.get("limit"))) - after: tuple[date, uuid.UUID] | None = None + after: tuple[date, datetime, uuid.UUID] | None = None raw_cursor = request.args.get("cursor") if raw_cursor: after = _decode_statement_cursor(raw_cursor) @@ -92,7 +101,13 @@ def list_statements() -> Any: ) as uow: snapshots = uow.published_snapshots.list_for_customer(customer_id, limit=limit, after=after) page = paginate( - snapshots, limit=limit, cursor_key=lambda s: (s.period_start.isoformat(), str(s.id)) + snapshots, + limit=limit, + cursor_key=lambda s: ( + s.period_start.isoformat(), + s.publish_watermark.isoformat(), + str(s.id), + ), ) view = StatementsListResponse( statements=[ diff --git a/backend/app/models/restatement/published_snapshot.py b/backend/app/models/restatement/published_snapshot.py index 20853bc..9fe6104 100644 --- a/backend/app/models/restatement/published_snapshot.py +++ b/backend/app/models/restatement/published_snapshot.py @@ -71,20 +71,30 @@ def list_for_customer( customer_id: uuid.UUID, *, limit: int = DEFAULT_PAGE_SIZE, - after: tuple[date, uuid.UUID] | None = None, + after: tuple[date, datetime, uuid.UUID] | None = None, ) -> list[PublishedSnapshot]: """`GET /api/v1/statements` (S6 §8) -- every snapshot ever published for this customer, including every watermark a period was republished under (FR-26: each one must stay - independently queryable). Keyset-paginated on `(period_start, id)` DESC (S12 §8).""" + independently queryable). Keyset-paginated on `(period_start, publish_watermark, id)` DESC + (S12 §8) -- `period_start` is not unique per customer (a period can be republished under + several watermarks, FR-26), so `publish_watermark` is required to preserve + `latest_for_period`'s own "most recent republish first" ordering; `id` remains only as a + final tamper-evident tiebreak for a true `publish_watermark` collision.""" statement = select(PublishedSnapshot).where(PublishedSnapshot.customer_id == customer_id) if after is not None: - after_period_start, after_id = after + after_period_start, after_publish_watermark, after_id = after statement = statement.where( - tuple_(PublishedSnapshot.period_start, PublishedSnapshot.id) - < (after_period_start, after_id) + tuple_( + PublishedSnapshot.period_start, + PublishedSnapshot.publish_watermark, + PublishedSnapshot.id, + ) + < (after_period_start, after_publish_watermark, after_id) ) statement = statement.order_by( - PublishedSnapshot.period_start.desc(), PublishedSnapshot.id.desc() + PublishedSnapshot.period_start.desc(), + PublishedSnapshot.publish_watermark.desc(), + PublishedSnapshot.id.desc(), ).limit(limit + 1) return list(self.session.execute(statement).scalars().all()) diff --git a/backend/tests/api/test_statements.py b/backend/tests/api/test_statements.py index de72908..4144323 100644 --- a/backend/tests/api/test_statements.py +++ b/backend/tests/api/test_statements.py @@ -56,18 +56,26 @@ def _publish_snapshot( *, period_start: date, period_end: date, + publish_watermark: datetime | None = None, + snapshot_id: uuid.UUID | None = None, ) -> uuid.UUID: + watermark = ( + publish_watermark + if publish_watermark is not None + else datetime(period_end.year, period_end.month, 28, tzinfo=UTC) + ) session = Session(bind=owner_engine, expire_on_commit=False) try: snapshot = PublishedSnapshot( + id=snapshot_id if snapshot_id is not None else uuid.uuid4(), customer_id=customer_id, period_start=period_start, period_end=period_end, - publish_watermark=datetime(period_end.year, period_end.month, 28, tzinfo=UTC), + publish_watermark=watermark, twr=Decimal("0.0100000000"), balance=Money("5000.00"), holdings_json={}, - published_at=datetime(period_end.year, period_end.month, 28, tzinfo=UTC), + published_at=watermark, ) session.add(snapshot) session.commit() @@ -103,6 +111,39 @@ def test_lists_published_statements_newest_period_first( assert body["next_cursor"] is None +def test_a_republished_period_orders_by_newest_publish_watermark_first( + api_client: FlaskClient, owner_engine: Engine +) -> None: + """FR-26: `period_start` is not unique per customer -- a period can be republished under + several watermarks, and `latest_for_period` orders those by `publish_watermark DESC` + specifically to mean "most recent republish first". The pagination cursor key must preserve + that same order rather than falling back to `id` (unrelated to republish recency): the older + republish is given the numerically LARGER `id` and is inserted FIRST here, so an + implementation that (incorrectly) tiebreaks on `id` would order it first -- the wrong answer + -- rather than this test passing by insertion-order or id-magnitude coincidence.""" + customer_id, _ = _register_and_login(api_client) + older_watermark = datetime(2026, 2, 1, tzinfo=UTC) + newer_watermark = datetime(2026, 3, 1, tzinfo=UTC) + _publish_snapshot( + owner_engine, uuid.UUID(customer_id), + period_start=date(2026, 1, 1), period_end=date(2026, 1, 31), + publish_watermark=older_watermark, snapshot_id=uuid.UUID(int=2**128 - 1), + ) + _publish_snapshot( + owner_engine, uuid.UUID(customer_id), + period_start=date(2026, 1, 1), period_end=date(2026, 1, 31), + publish_watermark=newer_watermark, snapshot_id=uuid.UUID(int=0), + ) + + response = api_client.get("/api/v1/statements") + + assert response.status_code == 200 + body = response.get_json() + assert len(body["statements"]) == 2 + watermarks = [datetime.fromisoformat(s["publish_watermark"]) for s in body["statements"]] + assert watermarks == [newer_watermark, older_watermark] + + def test_a_customer_never_sees_another_customers_statements( api_client: FlaskClient, owner_engine: Engine ) -> None: diff --git a/backend/tests/integration/test_published_snapshot_repository_pagination.py b/backend/tests/integration/test_published_snapshot_repository_pagination.py index ed299dc..da4315f 100644 --- a/backend/tests/integration/test_published_snapshot_repository_pagination.py +++ b/backend/tests/integration/test_published_snapshot_repository_pagination.py @@ -62,13 +62,21 @@ def _seed_snapshot( return snapshot -def _cursor_key(snapshot: PublishedSnapshot) -> tuple[str, str]: - return (snapshot.period_start.isoformat(), str(snapshot.id)) +def _cursor_key(snapshot: PublishedSnapshot) -> tuple[str, str, str]: + return ( + snapshot.period_start.isoformat(), + snapshot.publish_watermark.isoformat(), + str(snapshot.id), + ) -def _decode_after(raw_cursor: str) -> tuple[date, uuid.UUID]: +def _decode_after(raw_cursor: str) -> tuple[date, datetime, uuid.UUID]: decoded = decode_cursor(raw_cursor) - return date.fromisoformat(decoded[0]), uuid.UUID(decoded[1]) # type: ignore[arg-type] + return ( + date.fromisoformat(decoded[0]), # type: ignore[arg-type] + datetime.fromisoformat(decoded[1]), # type: ignore[arg-type] + uuid.UUID(decoded[2]), # type: ignore[arg-type] + ) def test_list_for_customer_pagination_is_stable_under_a_mid_walk_insert(db_committing) -> None: diff --git a/backend/tests/unit/test_pagination_cursor_keys.py b/backend/tests/unit/test_pagination_cursor_keys.py index 342cadb..b84cb68 100644 --- a/backend/tests/unit/test_pagination_cursor_keys.py +++ b/backend/tests/unit/test_pagination_cursor_keys.py @@ -79,16 +79,24 @@ def test_valuation_history_cursor_key_round_trips() -> None: assert uuid.UUID(decoded[2]) == posting_id # type: ignore[arg-type] -# --- GET /statements: (period_start, id) DESC ----------------------------------------------------- +# --- GET /statements: (period_start, publish_watermark, id) DESC --------------------------------- def test_statements_cursor_key_round_trips() -> None: + """`publish_watermark` is required, not just `id`: `period_start` is not unique per customer + (a period can be republished under several watermarks, FR-26), and `publish_watermark` is what + preserves `latest_for_period`'s own "most recent republish first" ordering across a page + boundary -- `id` remains only as the final tamper-evident tiebreak for a true collision.""" period_start = date(2026, 8, 1) + publish_watermark = datetime(2026, 9, 1, 8, 30, tzinfo=UTC) snapshot_id = uuid.uuid4() - cursor = encode_cursor(period_start.isoformat(), str(snapshot_id)) + cursor = encode_cursor( + period_start.isoformat(), publish_watermark.isoformat(), str(snapshot_id) + ) decoded = decode_cursor(cursor) - assert len(decoded) == 2 + assert len(decoded) == 3 assert date.fromisoformat(decoded[0]) == period_start # type: ignore[arg-type] - assert uuid.UUID(decoded[1]) == snapshot_id # type: ignore[arg-type] + assert datetime.fromisoformat(decoded[1]) == publish_watermark # type: ignore[arg-type] + assert uuid.UUID(decoded[2]) == snapshot_id # type: ignore[arg-type] From e43326d532e6a0f766374087c7a27383c119bd5e Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Wed, 16 Sep 2026 12:41:52 -0700 Subject: [PATCH 06/14] feat(frontend): keyset "Load more" pagination on orders, lots, funding history, and statements Task 5 of the fullstack-rebuild plan. Extends the canonical read-hook shape (Global Constraints) with a `loading-more` status and a `loadMore` action, consuming Task 4's `next_cursor` contract exactly: `useOrders.js`, `useLots.js`, `useStatements.js`, and two new hooks split out for the upcoming Task 8/10 page rewrites -- `useFundingHistory.js` (cash-summary fetching stays on the existing `useFunding.js` for now, per the plan) and `useValuationHistory.js`. `loadMore` appends rather than replaces; `refetch` still means page 1, full replacement. New `paginationQuery.js` centralizes the query-string building all five hooks share. UI: `OrderList.jsx` and `FundingHistoryTable.jsx` get a trailing "Load more" button, disabled/loading while a page is in flight, hidden once `nextCursor` is null. Built against Task 4's real, already-committed backend, not a mock. Also commits `docs/superpowers/plans/2026-09-08-trueup-fullstack-rebuild.md`, the plan this branch follows -- it needed a home in the series. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p --- .../2026-09-08-trueup-fullstack-rebuild.md | 864 ++++++++++++++++++ .../components/FundingHistoryTable.jsx | 85 +- .../funding/hooks/useFundingHistory.js | 43 + frontend/src/features/lots/hooks/useLots.js | 34 +- .../features/orders/components/OrderList.jsx | 47 +- .../src/features/orders/hooks/useOrders.js | 28 +- .../statements/hooks/useStatements.js | 32 +- .../features/valuation/api/valuationApi.js | 2 + .../valuation/hooks/useValuationHistory.js | 43 + frontend/src/utils/paginationQuery.js | 11 + 10 files changed, 1122 insertions(+), 67 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-08-trueup-fullstack-rebuild.md create mode 100644 frontend/src/features/funding/hooks/useFundingHistory.js create mode 100644 frontend/src/features/valuation/hooks/useValuationHistory.js create mode 100644 frontend/src/utils/paginationQuery.js diff --git a/docs/superpowers/plans/2026-09-08-trueup-fullstack-rebuild.md b/docs/superpowers/plans/2026-09-08-trueup-fullstack-rebuild.md new file mode 100644 index 0000000..a81eb9d --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-trueup-fullstack-rebuild.md @@ -0,0 +1,864 @@ +# Trueup full-stack rebuild — SDD execution plan + +Source of truth this plan argues from: `/Users/badrinarayanan/.claude/plans/yes-the-new-design-purring-sutherland.md` +(the plan-mode plan the user approved) and, beneath it, `docs/specs/frontend/{design-system,structure}.md`, +`docs/specs/08-surfaces.md`, `docs/specs/12-production-operations.md` §6, `docs/decisions/{01,04,05,20,26,27}*.md`, +and `docs/requirements/requirements.md`. Where this plan and one of those disagree, the spec wins; +flag the conflict in the ledger rather than silently picking one. + +Branch: `feature_ui_revamp_fullstack` off `main`, in an isolated worktree. Do not work on `main` or +on any other branch. Chat (`ChatPage.jsx` and everything under `features/chat/`) is explicitly kept +exactly as it is today — no task in this plan touches its behavior, only (Task 7) the shell CSS +around it. + +Tasks execute **in numeric order, one at a time** (never dispatch two implementers in parallel). +The order already reflects the dependency graph — do not reorder without a ledgered ruling. + +## Global Constraints + +These bind every task. Copy the relevant subset verbatim into each task reviewer's context. + +### Backend conventions (`backend/CLAUDE.md`) +- Layering, strictly one direction: `controllers → services → models → core`; `services` reach + external providers only through `Protocol`s in `app/integrations/ports.py`, never a concrete + adapter — enforced by `import-linter` (`backend/.importlinter`), run via `uv run lint-imports`. +- Every money/unit/price value uses `Money`/`Units`/`Price` from `app/core/money.py` — never a bare + `Decimal`, never a float. On the wire, every `Money`/`Units`/`Price` field and every `Decimal` + with an explicit `@field_serializer(..., when_used="json")` is a **JSON string** — `"1234.5600"`, + not `1234.56`. Reject a float `amount`/`quantity`/`price` in a request body; require a string. +- Response schemas live only in `app/views/`, as explicit Pydantic models — never serialize an ORM + entity directly, and `app/views/` may import `app/core` only (not `app/models`, not `app/services`). +- Error body is always exactly `{type: "about:blank", title, status, code, correlation_id}` — no + `detail` field ever leaves the server. New error codes are added as a specific `AppError` subclass + or an explicit code string on a `ValidationError`/`NotFoundError`/etc.; never invent a new shape. +- Every new route needs its own `@limiter.limit("N per minute")` — there are no default limits. + Match the limit style of the nearest analogous existing route (reads 60/min, writes 10-30/min). +- Every repository read of a bitemporal aggregate takes `as_of: Watermark` with no default. +- Canonical four-file pattern for a new read-only endpoint (copy this shape exactly): controller in + `app/controllers/api/.py` (blueprint, `@limiter.limit(...)`, role/ownership check, opens a + `with UnitOfWork(...) as uow:` block, returns `jsonify(view.model_dump(mode="json")), 200`) → + view in `app/views/.py` (Pydantic schemas only) → service in + `app/services//_service.py` (constructor-injected UoW, one responsibility) → + registered in `app/__init__.py` (import near the top + `app.register_blueprint(...)` in + `create_app`) → test in `tests/api/test_.py` using the `api_client` fixture + (`tests/api/conftest.py`), asserting the exact whole response body, not just status code. +- Gates before any backend task is done: `uv run pytest` (all of `tests/unit`, `tests/integration`, + `tests/api`), `uv run mypy --strict app`, `uv run ruff check app tests`, `uv run lint-imports`. + Coverage floor is 77% (`pyproject.toml`). Tests need Postgres on `:5433` and Redis on `:6380/1` + (`docker compose up -d` from the repo root) — never SQLite. +- Any schema change ships an Alembic migration in the same commit + (`uv run alembic revision --autogenerate -m "..."`, then hand-review it, then + `uv run alembic upgrade head` to prove it applies cleanly). +- Auth/session facts that affect API design: session cookie is `SameSite=Strict`, no CORS is + configured — same-origin only. CSRF token is issued as `csrf_token` in login/session responses + and sent back as header `X-CSRFToken` on every mutating request; a missing/bad token is **400**, + not 401/403. A staff (`adviser`/`admin`) session must pass `?customer_id=` on every read a + customer-scoped route serves; a customer session never does (reads `flask.session["_user_id"]`). + +### Frontend conventions (`frontend/CLAUDE.md`) +- Structure: `src/features//{api,hooks,components}/`, `src/pages/` (thin route screens that + compose features), `src/components/` (generic reusable UI — all 15 existing components: Badge, + Button, Card, DetailFields, EmptyState, ErrorState, Icon, Input, Select, SimulatedBadge, Skeleton, + StatusPill, Table, Toast, UnitsValue — are **IA-agnostic and must not be structurally rewritten or + redesigned** by this plan; reuse them as-is. This does not forbid a purely additive extension a + later task's own data genuinely requires and that leaves every existing consumer's behavior + unchanged — e.g. Task 1 adding new entries to `Icon/paths.js` without touching any existing path. + If a task's brief explicitly calls for such an addition, follow the brief; if you find yourself + wanting to change an existing prop, class, or behavior on one of these 15 components, that is the + line — stop and report it as a concern rather than doing it), `src/hooks/` (cross-feature hooks), + `src/services/` (network/config/external integration), `src/utils/` (pure JS, no React). +- **Canonical data hook shape** — copy this exactly for every new read hook (verbatim reference: + `frontend/src/features/valuation/hooks/useBalance.js`): + ```js + const IDLE = { status: 'idle', : null /* or [] */, error: null } + export function useX(...) { + const [state, setState] = useState(IDLE) + const refetch = useCallback(async () => { + setState((prev) => ({ ...prev, status: 'loading', error: null })) // keeps last-good data + try { + const data = await xApi.get(...) + setState({ status: 'loaded', : , error: null }) + } catch (error) { + setState({ status: 'error', : null, error }) + } + }, [/* query deps */]) + useEffect(() => { refetch() }, [refetch]) + return { ...state, refetch } + } + ``` + Mutation hooks mirror this with `status: 'idle'|'submitting'|'submitted'|'error'`, and **re-throw** + on failure (read hooks never throw). `error` is always the raw `ApiError` instance from + `src/services/apiClient.js` — map it to text only at the component boundary via + `getErrorMessage(error)` from `src/utils/apiErrorMessage.js`, fed into + ``. +- `src/services/apiClient.js` is the only fetch wrapper — `apiClient.get/post/patch(path, body, { + idempotencyKey })`, throws `ApiError { status, code, type, correlationId }`. Do not add a second + fetch wrapper or call `fetch` directly anywhere outside `apiClient.js` and the chat SSE reader + (which is intentionally separate — do not touch it). +- Every page renders four states explicitly: loading (a named-shape `Skeleton`, never a bare + spinner), empty (`EmptyState`, `variant="good"` when empty is the desired state), error + (`ErrorState` with `onRetry`), success. This is a component-test requirement, not a nicety. +- Gates before any frontend task is done: `npm run lint` (oxlint), `npm run build`, and — from + Task 1 onward — `npm test` (Vitest). A component is not done without a test covering render, + interaction, and loading/empty/error states, per `frontend/CLAUDE.md`. +- **The Chat layout contract, do not break it**: `.tu-app-layout` (`flex column`) → + `.tu-app-layout__main { display:flex; flex-direction:column; flex:1; min-height:0 }` → down + through `.tu-chat-page` → `.tu-chat-window__messages { overflow-y:auto }` is what lets the chat + transcript scroll internally instead of the whole page scrolling. Any wrapper this plan puts + around `` must carry those same four properties on the element that plays `__main`'s role. + +### Commit / PR discipline +- One commit (or a small tight series) per task, on `feature_ui_revamp_fullstack`. Do not open a PR + per task — the whole branch is one PR at the end, per the root `CLAUDE.md`'s PR process, opened + only after `finishing-a-development-branch` is invoked at the very end of this plan. +- Every commit message ends with the attribution lines the controller session was given (ask the + controller if unsure — do not fabricate an attribution block). +- Never commit `.env`, `backend/.venv/`, `frontend/node_modules/`, or `frontend/dist/`. + +--- + +## Task 1: Frontend foundations — Vitest, extended type tokens, theme toggle + +This is the first frontend task and the only one with no dependency on any backend task. It +unblocks every later frontend task's test-writing requirement. + +**1a. Install the test runner.** `frontend/package.json` currently has no test infrastructure at +all — no `vitest`, no `@testing-library/*`, no `test` script, despite `frontend/CLAUDE.md` +documenting `npm test`. Add as devDependencies: `vitest`, `@vitest/coverage-v8`, `jsdom`, +`@testing-library/react`, `@testing-library/user-event`, `@testing-library/jest-dom`. Add scripts +`"test": "vitest run"` and `"test:watch": "vitest"`. Add a `test` block to `vite.config.js` +(`environment: 'jsdom'`, `setupFiles: ['./src/test/setup.js']`, `globals: true`). Create +`src/test/setup.js` importing `@testing-library/jest-dom` matchers and mocking `window.matchMedia` +(needed for `prefers-color-scheme`/`prefers-reduced-motion` — jsdom has no real implementation). +Write one trivial passing test (e.g. for the existing `formatMoney` in `src/utils/format.js`) to +prove the harness works end-to-end. + +**1b. Extend the type scale — additive only.** In `frontend/src/styles/tokens.css`, add three new +tokens to the existing `:root` block, without touching any of the 17 existing color tokens or the +existing type tokens (`--font-size-display` etc. stay exactly as they are): +```css +--font-size-display-xl: 56px; --line-height-display-xl: 60px; --font-weight-display-xl: 600; +--font-size-title: 28px; --line-height-title: 34px; --font-weight-title: 600; +--font-size-section: 20px; --line-height-section: 28px; --font-weight-section: 600; +``` +`display-xl` and `title`/`section` use `var(--font-serif)` when applied (that's a consumer-side +choice in later tasks, not a new font-family token here). Do not add these tokens to the dark-mode +blocks — type tokens aren't theme-dependent in this codebase (only color tokens are duplicated +per-theme); check `tokens.css`'s existing structure and follow its own pattern exactly. + +**1c. Theme context + toggle.** `docs/specs/frontend/design-system.md` §2.2 requires "an explicit +in-app toggle persisted per user" and it has never been built — `data-theme` is currently never set +by any JS anywhere in the codebase (confirmed by grep). `tokens.css` already has both +`@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) {...} }` and +`:root[data-theme='dark'] {...}` blocks waiting for the attribute to ever be set. + +Create `src/contexts/ThemeContext.jsx`: `ThemeProvider({children})` reads `localStorage` key +`trueup.theme.v1` (values `'light'|'dark'|'system'`, default `'system'`) wrapped in try/catch (a +private-mode or storage-disabled browser must not throw — fall back to `'system'` silently), applies +`document.documentElement.setAttribute('data-theme', value)` for `'light'`/`'dark'`, or +`removeAttribute('data-theme')` for `'system'`, on mount and on every change; writes back to +`localStorage` in a try/catch on every change. Exposes `{theme, setTheme}` via `useTheme()`. + +Create `src/components/ThemeToggle/{ThemeToggle.jsx,ThemeToggle.css,index.js}` — a single icon +button that cycles light → dark → system → light on click, following the existing `Icon` component's +16×16 stroke-path convention (add three new paths — sun, moon, a system/monitor glyph — to +`src/components/Icon/paths.js` rather than inventing a separate icon mechanism), `aria-label` +reflecting the *next* state it will switch to (e.g. "Switch to dark theme"), visible focus ring per +the existing `:focus-visible` global rule. Do not mount it in the app shell yet — that's Task 7. Just +build it and its own component test (renders, cycles on click, persists to `localStorage`, falls +back cleanly when `localStorage` throws). + +**Do not** touch `AppLayout.jsx`, `App.jsx`, or any page file in this task. + +**Report:** the exact `npm test` output (pass count), confirmation the trivial `formatMoney` test +and the `ThemeToggle` test both pass, and confirmation `npm run build` still succeeds with the new +tokens present. + +--- + +## Task 2: Backend — Watchlist read endpoint (`GET /api/v1/securities`) + +No migration. `security` and `daily_close` tables already exist (`app/models/marketdata/`). +`Security.status` is already `active|inactive`; `DailyClose.source` is already `live|simulated` — +this is the exact provenance value the existing `SimulatedBadge` frontend component (unchanged, +reused in Task 8) is built to render, so pass it through unmodified, never re-derive or hardcode it. + +**Repository additions** (edit, don't replace, the existing files): +- `app/models/marketdata/security.py` — add `SecurityRepository.list_active(self, *, limit: int, + after: tuple[str, uuid.UUID] | None) -> list[Security]`: filters `status == SecurityStatus.ACTIVE`, + orders `ORDER BY symbol, id`, applies a keyset `WHERE (symbol, id) > (after[0], after[1])` when + `after` is given, fetches `limit + 1` rows (the `+1` is how the service layer knows whether a + further page exists — do not fetch `limit` and separately guess). +- `app/models/marketdata/daily_close.py` — add `DailyCloseRepository.latest_for_securities(self, + security_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, DailyClose]`: **one query**, not a loop — + `SELECT DISTINCT ON (security_id) * FROM daily_close WHERE security_id = ANY(:ids) ORDER BY + security_id, market_date DESC, recorded_at DESC` (via SQLAlchemy `.distinct(DailyClose.security_id)` + with matching `.order_by(...)`). **Order `market_date DESC` before `recorded_at DESC`** — a late + correction to an old date must never be returned as "latest" over a genuinely newer close. Return + `{}` for an empty `security_ids` input without querying. + +**Service** — new `app/services/marketdata/__init__.py` (empty package init — `app/services/marketdata/` +does not exist yet) and `app/services/marketdata/security_catalog_service.py`: +`SecurityCatalogService(uow: MarketDataUnitOfWork)`, method `list_active(self, *, limit: int, after: +str | None) -> tuple[list[tuple[Security, DailyClose | None]], str | None]` — decodes the opaque +cursor via `app/core/pagination.py`'s `decode_cursor`/`encode_cursor` (2-tuple `(symbol, id)`), +calls the two repo methods above (batched, not per-security), zips results, and returns the next +cursor (`None` on the last page) using the same `+1`-row convention `app/controllers/admin/customers.py` +already uses — read that file for the exact pattern before writing this, don't reinvent the pagination +math. + +**Controller** — new `app/controllers/api/securities.py`: `securities_bp = Blueprint("securities", +__name__, url_prefix="/api/v1/securities")`, `GET ""`, `@limiter.limit("60 per minute")`, any +authenticated session (customer or staff — a security catalogue is not tenant-scoped, so no +ownership check, just confirm `current_user.is_authenticated`), query param `?limit=&after=` +(`normalize_limit` from `app/core/pagination.py`, default 50 max 200, **rejects** out-of-range — +does not clamp), opens `with MarketDataUnitOfWork(role=) as uow:` (this UoW is +not tenant-scoped — pass `customer_id=None`), calls the service, returns 200. + +**View** — new `app/views/securities.py`: +```python +class DailyCloseSummaryResponse(BaseModel): + price: Price + market_date: date + source: str # "live" | "simulated" + status: str # "confirmed" | "stale" | "missing" + +class SecurityResponse(BaseModel): + security_id: uuid.UUID + symbol: str + name: str + asset_class: str # "equity" | "bond" + last_close: DailyCloseSummaryResponse | None # null when the security has never had a close + +class SecuritiesListResponse(BaseModel): + securities: list[SecurityResponse] + next_cursor: str | None +``` + +**Register**: import + `app.register_blueprint(securities_bp)` in `app/__init__.py`, in the same +block as the other `api/` blueprints. + +**Tests**: +- `tests/integration/test_security_catalog_repositories.py` — `list_active` respects the `status` + filter and keyset pagination (insert 3 active + 1 inactive security, page with `limit=2`, assert + the inactive one never appears and the second page picks up exactly where the first left off); + `latest_for_securities` returns the newest `market_date` (not the newest `recorded_at` of an older + date) and, separately, a test that a late-arriving correction (`recorded_at` newer, same + `market_date`) wins over the original row for that date; assert the batched call issues exactly + one query for N securities (use pytest's query-count fixture if one exists in `tests/conftest.py`, + otherwise assert via `sqlalchemy.event` query counting — check `tests/integration/` for an existing + pattern before inventing one). +- `tests/api/test_securities.py` — whole-body-equality assertions per `tests/api/test_profile.py`'s + style: happy path with a live close, happy path with `last_close: null` (security with no close + row at all), `source: "simulated"` label passes through untouched, 401 for an anonymous request, + pagination round-trip (`next_cursor` present mid-list, `null` on the last page), `limit=201` → 422. + +**Report:** full `uv run pytest tests/api/test_securities.py +tests/integration/test_security_catalog_repositories.py -v` output, plus a full `uv run pytest` +run confirming nothing else broke, plus `uv run mypy --strict app` and `uv run lint-imports` clean. + +--- + +## Task 3: Backend — Specific-ID tax-lot designation at sell time (FR-20 / ADR-4) + +**Read this first, it changes the shape of the task**: the consumption domain logic already exists +and is already tested. `backend/app/services/lots/lot_consumption_service.py`'s +`LotConsumptionService.record_sell_fill(..., designated_lot_ids: Sequence[uuid.UUID] | None = None)` +already fully implements the specific-ID branch via its private `_select_lots_to_consume` — it +validates each named lot belongs to the customer/security (raising `UnknownTaxLotError` otherwise), +sets `lot.designation = LotDesignation.SPECIFIC`, and consumes exactly those lots. +`tests/integration/test_lot_consumption_service.py` already covers this, including the +wrong-customer and nonexistent-lot cases. **Do not modify `lot_consumption_service.py`'s consumption +logic** — it is correct and tested. Confirm this for yourself by reading the file before starting; +if it does not match this description, stop and report `BLOCKED` rather than guessing. + +The actual gap: nothing in the request path ever populates `designated_lot_ids`. `POST /orders` +accepts no lot parameter, and fills arrive asynchronously via `trade_update_handler.py`'s webhook +handler with no way to recover what the customer originally asked for. + +**3a. Migration.** Add a nullable `designated_lot_ids` column to the `order` table: +`Mapped[list[uuid.UUID] | None] = mapped_column(postgresql.ARRAY(UUID(as_uuid=True)), nullable=True)` +in `app/models/orders/order.py`. `NULL` means FIFO (today's behavior, unchanged for every existing +order). This is a property of the order request itself (same category as `side`/`quantity_requested`), +not a new aggregate — do not create a join table, and do not add a foreign key from `order` to +`tax_lot` (the codebase already deliberately avoids an `order → security` FK for the same boundary +reason; read the existing `order.py` module docstring on this before arguing otherwise in a +self-review). **No new column on `lot_consumption` or `tax_lot`** — `tax_lot.designation` and +`designation_window_closes_at` already record everything needed. Generate the migration with +`uv run alembic revision --autogenerate -m "add order.designated_lot_ids"`, hand-review the +generated file (autogenerate sometimes gets `ARRAY` column defaults wrong — check it explicitly +sets no server default and is nullable), then `uv run alembic upgrade head` to prove it applies. + +**3b. `OrderRepository.open_sell_orders`.** Add to `app/models/orders/order.py`'s repository, mirroring +the existing `open_buy_orders()` method exactly (same non-terminal-status filter, same shape) but +filtered to `side == OrderSide.SELL`. This is what lets the netting check in 3c avoid two sequential +sells claiming the same lot. + +**3c. New `app/services/orders/lot_designation_service.py`** — `LotDesignationService(uow: +OrdersUnitOfWork)`, method `validate(self, *, customer_id, security_id, side, quantity: Units, +lot_ids: Sequence[uuid.UUID] | None) -> list[uuid.UUID] | None` (returns the validated list, or +`None` unchanged, to be stored on the order): +1. `lot_ids` present and `side != SELL` → raise a new `LotDesignationNotAllowedForBuyError`. +2. `lot_ids == []` (present but empty) → raise a new `EmptyLotDesignationError` — an explicit empty + list is a client bug, distinct from omitting the field (which means FIFO). +3. `lot_ids` has duplicates → raise a new `DuplicateLotDesignationError`. +4. `len(lot_ids) > 50` → raise a new `TooManyDesignatedLotsError` (OWASP API4, check this before any + DB round-trip). +5. Every id must resolve to a `TaxLot` matching `(customer_id, security_id)` — reuse + `TaxLotRepository.lock_by_ids` (already exists) and reuse the existing `UnknownTaxLotError` from + `lot_consumption_service.py` for a not-found/wrong-owner id — do not invent a second exception + for the same condition. +6. **Coverage-with-netting**: `sum(lot.quantity_remaining for lot in named_lots) - + sum(open_sell.quantity_requested - open_sell.filled_quantity for open_sell in + open_sell_orders(customer_id) if open_sell.designated_lot_ids overlaps named lot ids and + open_sell.id != this_order) >= quantity`. Else raise a new + `InsufficientDesignatedLotsError`. **There is no FIFO fallback for an uncovered remainder, at + placement or at fill time** — a specific-ID designation determines the customer's realized + gain/loss, and silently substituting FIFO for a shortfall would produce a different tax outcome + than the one they chose. At fill time, the existing `InsufficientLotsError` (already raised by + `record_sell_fill`) is correct behavior for a race the netting check narrows but cannot fully + close — it fails the fill for manual reconciliation, which is what the break queue exists for. + +**3d. Wire the controller.** `app/controllers/api/orders.py`'s `CreateOrderRequest` gains +`lot_ids: list[uuid.UUID] | None = None`. In `create_order`, call +`LotDesignationService(uow).validate(...)` before constructing the order, store its return value on +`Order.designated_lot_ids`. Map each new exception to a 422 with a distinct `code`: +`lot_designation_not_allowed_for_buy`, `empty_lot_designation`, `duplicate_lot_id`, +`too_many_designated_lots`, `unknown_tax_lot` (reuse), `insufficient_designated_lots`. + +**3e. Fill-time pass-through.** `app/services/orders/trade_update_handler.py`'s sell branch — the +call to `lot_service.record_sell_fill(...)` — add `designated_lot_ids=order.designated_lot_ids`. +One argument. Confirm (read the loop, don't assume) that a partial fill correctly re-reads +`order.designated_lot_ids` on each webhook event and that `TaxLot.quantity_remaining` persisting +between calls makes a repeated designation across multiple partial fills correct with no further +change — if you find it is NOT correct, that's a real finding, report it rather than silently +patching around it. + +**3f. Response.** `app/views/orders.py`'s `OrderResponse` gains `designated_lot_ids: list[str] | +None` (stringified UUIDs, matching the existing string-id convention on `LotResponse`). + +**Tests:** +- `tests/unit/test_lot_designation_service.py` (new) — one test per validation rule (1-6 above), + including the netting case: two sell orders naming an overlapping lot, second one rejected; + and the boundary case where combined `quantity_remaining` exactly equals `quantity` (must pass, + not fail as a false negative). +- `tests/api/test_orders.py` (edit) — one test per new error code mapped to 422, and a happy-path + `POST /orders` with `lot_ids` that persists `designated_lot_ids` and is echoed in the response. +- `tests/integration/test_lot_consumption_service.py` — add exactly one new test: two sequential + partial fills on one sell order, same `designated_lot_ids` passed both times, assert the second + fill correctly continues consuming from where the first left off (do not touch the existing tests + in this file — they already cover the base consumption logic). + +**Report:** the exact `alembic upgrade head` output proving the migration applies; full +`uv run pytest` output; `uv run mypy --strict app` and `uv run lint-imports` clean; explicitly state +whether 3e's partial-fill re-designation check passed as expected or surfaced a real gap. + +--- + +## Task 4: Backend — Pagination on 5 unbounded customer list endpoints + +Reuse `app/core/pagination.py` verbatim — `normalize_limit(limit, default=50, maximum=200)` (rejects +out-of-range, never clamps), `encode_cursor(*values)`/`decode_cursor(cursor)` (opaque, tamper-evident, +max 2048 chars), `paginate(rows, *, limit, cursor_key) -> Page[T]` (`.items`, `.next_cursor`, +`.has_more`). Copy the exact usage pattern from `app/controllers/admin/customers.py::list_customers` +— do not reinvent cursor math. Every change below is purely additive to the response shape +(`next_cursor: str | None` added; every existing field stays); do not remove or rename any existing +field. + +| Endpoint | File | Cursor key | Note | +| --- | --- | --- | --- | +| `GET /orders` | `app/controllers/api/orders.py` + `app/models/orders/order.py` | `(created_at, id)` DESC | Today's ordering has no `id` tiebreak — add one, or the cursor is unstable across rows with an identical `created_at` | +| `GET /lots` | `app/controllers/api/lots.py` + `app/models/ledger/tax_lot.py` | `(acquired_at, id)` ASC | Preserve the existing oldest-first order — do not flip to DESC | +| `GET /funding/history` | `app/controllers/api/funding.py` + wherever `FundingHistoryService` reads from | `(effective_date, recorded_at, journal_entry_id)` DESC | Two entries can share both dates; the 3rd key is required for a stable cursor | +| `GET /valuation/history` | `app/controllers/api/valuation.py` + the posting read source | `(effective_date, recorded_at, posting_id)` DESC | The cursor may reference `posting_id`, a field not present in the response body at all — that's fine, the cursor is opaque | +| `GET /statements` | `app/controllers/api/statements.py` + `app/models/restatement/published_snapshot.py` | `(period_start, id)` DESC | Newest period first, matching today's order | + +For each: add a `list_for_customer(self, customer_id, *, limit: int, after: tuple | None) -> +list[T]` (or extend the existing list method with `limit`/`after` params if one already exists — +check first, don't create a duplicate method) on the owning repository, fetching `limit + 1` rows +with the keyset `WHERE` clause; add `?limit=&after=` query params to the controller +(`normalize_limit` + `decode_cursor`); add `next_cursor: str | None` to the corresponding response +schema in `app/views/{orders,lots,funding,valuation,statements}.py`. + +**This task does not change frontend behavior** — Task 5 does, immediately after, consuming this +exact contract. Do not skip writing the `next_cursor` field correctly because "the frontend isn't +ready yet" — an unpaired backend default-50 change without Task 5 would silently truncate the +current UI to 50 rows, which is why these two tasks are adjacent and both mandatory before Task 5 +of the frontend stream (Dashboard, Task 8) ships. + +**Tests:** +- `tests/unit/test_pagination_cursor_keys.py` (new) — each of the 5 cursor-key tuples round-trips + through `encode_cursor`/`decode_cursor` with the correct arity and types. +- Per endpoint, edit `tests/api/test_{orders,lots,funding,valuation_api,statements}.py`: `limit=201` + → 422; a tampered/malformed cursor → 422; the last page returns `next_cursor: null`; an earlier + page returns a non-null `next_cursor` that correctly fetches the next page with no duplicate and + no skipped row. +- Per repository, add an integration test asserting keyset stability: page through with `limit=2` + across 5 seeded rows, insert a new row that sorts into an already-yielded page's position mid-walk, + and assert no already-seen row is repeated and no unseen row is skipped. + +**Report:** full `uv run pytest` output; confirm all 5 endpoints' existing tests still pass +unmodified in substance (only the new pagination assertions are additions, not rewrites of existing +assertions) unless a specific existing test asserted the old unbounded-list shape, in which case +name exactly which test you had to change and why. + +--- + +## Task 5: Frontend — "Load more" pagination on the 5 corresponding hooks + +Consumes Task 4's contract exactly (`next_cursor: str | null` on each list response). Read the +actual `app/views/{orders,lots,funding,valuation,statements}.py` from Task 4's commits before +starting — do not assume the shape, confirm it. + +**Decision already made, don't relitigate it**: an explicit **"Load more" button, not infinite +scroll**. The app's real scroll container is non-obvious given the shell's flex-chain layout (see +Global Constraints), a button is trivially testable with Testing Library with no +`IntersectionObserver` mocking, and these are financial ledgers where reaching a definite end +matters. + +**Hook shape** — extend the canonical machine from Global Constraints with one more status and one +more action, in each of: `src/features/orders/hooks/useOrders.js`, +`src/features/lots/hooks/useLots.js`, a new `src/features/funding/hooks/useFundingHistory.js` +(split out of the existing `useFunding.js` — `useFunding.js` currently also owns cash-summary +fetching; leave the cash-summary half in place for now, Task 10 finishes that split), a new +`src/features/valuation/hooks/useValuationHistory.js`, and `src/features/statements/hooks/useStatements.js`: +```js +const IDLE = { status: 'idle', items: [], nextCursor: null, error: null } +// status: 'idle' | 'loading' | 'loaded' | 'loading-more' | 'error' +const loadMore = useCallback(async () => { + if (state.nextCursor === null || state.status === 'loading-more') return + setState((prev) => ({ ...prev, status: 'loading-more' })) + try { + const page = await xApi.list({ after: state.nextCursor }) + setState((prev) => ({ status: 'loaded', items: [...prev.items, ...page.items], nextCursor: page.next_cursor, error: null })) + } catch (error) { + setState((prev) => ({ ...prev, status: 'error', error })) + } +}, [state.nextCursor, state.status]) +``` +`refetch` keeps its existing meaning unchanged: page 1, full replacement, discards any loaded extra +pages. `hasMore` is derived (`nextCursor !== null`), never stored as a separate boolean. + +**UI**: each of the 5 corresponding list components (`OrderList`, `LotTable`, `FundingHistoryTable`, +a to-be-named valuation-history list component, `StatementList`) gets a trailing row/control: a +`Button variant="secondary" size="compact"` reading "Load more" below the list, `disabled` and +showing the existing `Button` `loading` prop while `status === 'loading-more'`, hidden entirely when +`nextCursor === null`. Do this even though `LotsPage`/`OrdersPage`/`FundingPage`/`StatementsPage` +are about to be restyled or deleted in Tasks 8-11 — this proves the hooks and their UI control work +against the real backend before the surrounding chrome changes. + +**Tests** (Vitest, using Task 1's harness): for each hook — `loadMore` appends rather than replaces; +`loadMore` is a no-op when `nextCursor === null`; `loadMore` is a no-op while already +`loading-more` (no double-fire on a fast double-click); `refetch` after a `loadMore` resets to page +1, discarding the extra pages; error during `loadMore` surfaces via `ErrorState` without discarding +already-loaded items (only the failed page is lost, not the list). For the "Load more" button +component: renders, click calls `loadMore`, disabled/loading state, absent when no more pages. + +**Report:** full `npm test` output, `npm run lint`, `npm run build`; confirm each of the 5 features +was tested against Task 4's actual committed backend (run the backend from this branch locally, not +a mock) at least once by hand or via one integration-style test, not purely unit-tested against a +stubbed `apiClient`. + +--- + +## Task 6: Backend — Real-time push (NFR-18: SSE + Redis Pub/Sub) + +Build exactly to `docs/specs/12-production-operations.md` §6: channels `customer::events` +and `adviser:events`; message shape `{event_type: str, entity_id: str, summary: str}` — a pointer +only, the client re-fetches, it must never trust this payload as authoritative data; publish +strictly **after** the triggering transaction's `uow.commit()`, never inside it (no I/O inside a +transaction, per S0 §5); a dropped/never-open SSE connection is not data loss, since nothing +published over Pub/Sub was ever the sole record of a fact — the next plain `GET` still returns truth. + +**6a. Port + adapter layering.** Add to `app/integrations/ports.py`: +```python +class EventBusPort(Protocol): + def publish(self, channel: str, message: Mapping[str, str]) -> None: ... + def subscribe(self, *channels: str) -> Iterator[Mapping[str, str]]: ... +``` +New `app/integrations/redis/__init__.py` + `app/integrations/redis/event_bus.py` — +`RedisEventBus(redis_url: str)` implementing the port: `publish` JSON-encodes and calls Redis +`PUBLISH`; `subscribe` opens a `pubsub()`, subscribes to the given channels, and yields decoded +messages from `get_message(timeout=...)` in a loop, yielding a heartbeat sentinel (e.g. +`{"event_type": "heartbeat"}`) when `timeout` elapses with nothing received (used by 6c to keep the +HTTP connection alive). New `app/integrations/fake/fake_event_bus.py` — `FakeEventBus` mirroring the +existing `fake_broker.py`'s style (in-memory, records published messages, a test can push a message +and assert `subscribe` yields it). + +**Edit `backend/.importlinter`**: add `app.integrations.redis` to the `services-use-ports-only` +contract's `forbidden_modules` list, alongside the existing `app.integrations.alpaca/plaid/stripe/azure` +entries — otherwise this contract silently stops covering the new adapter and `app/services` could +later import it directly without any lint catching it. Run `uv run lint-imports` after this edit to +confirm the contract is syntactically valid and currently passes. + +**6b. `app/services/ops/event_publisher.py`** (new) — `EventPublisher(bus: EventBusPort)` is the +only place a channel string or the message shape is constructed. Three methods: +`order_updated(customer_id, order_id, summary)`, `identity_status_changed(customer_id, gate: str, +summary)` (gate is `"kyc"` or `"account_approval"` — the two gates publish independently, never +merged into one event), `break_opened(break_id, summary)` (always publishes to `adviser:events`, +never a customer channel). + +**6c. Publish call sites — all strictly after the existing `uow.commit()` at that site:** +1. `app/services/orders/trade_update_handler.py::AlpacaTradeUpdateHandler.handle()` — after the + `with ... as uow: ... uow.commit()` block exits, call `event_publisher.order_updated(...)` on a + `fill`/`partial_fill` event and on every terminal non-filled status + (`TERMINAL_NON_FILLED_STATUSES`, already defined in this file). Inject `EventPublisher` via the + handler's constructor (mirror how it already receives other collaborators) — do not import + `RedisEventBus` here; the composition root (wherever this handler is constructed — + `app/jobs/__init__.py`, confirm the exact line) builds the real bus and passes the publisher in. +2. `app/controllers/webhooks/stripe_identity.py::_apply_verdict` — after each of its two + `uow.commit()` calls, publish `identity_status_changed` for whichever gate that commit changed. +3. `app/jobs/morning_reconciliation.py` — `ReconciliationService.run_morning_reconciliation()` + already returns `list[ReconciliationBreak]` (confirmed by reading the code — do **not** modify + `reconciliation_service.py`). After the job's own commit, iterate the returned breaks and call + `event_publisher.break_opened(break_row.id, ...)` for each, once per run, not inside + `reconciliation_service.py`'s per-break loop. + +**6d. New SSE endpoints.** +- `app/controllers/api/events.py` — `events_bp`, `GET /api/v1/events/stream`, self-scoped from + `flask.session["_user_id"]` (no `customer_id` param, same pattern as `profile.py`), + `@limiter.limit("10 per minute")` (governs connection establishment, not message volume — do not + rate-limit individual messages), returns `Response(stream_with_context(), + mimetype="text/event-stream")`. The generator subscribes to `customer::events`, yields + `data: \n\n` per message (same framing style as the existing chat SSE in `chat.py` — read it + for the exact framing before writing this), yields a `: ping\n\n` comment frame every 20 seconds + of no real message (proxies reap idle connections otherwise — this is not optional polish), and + closes cleanly after a hard maximum lifetime of ~30 minutes so `EventSource` on the client + reconnects rather than holding one worker forever. +- `app/controllers/admin/events.py` — `admin_events_bp`, `GET /api/v1/admin/events/stream`, + `@requires_role("adviser", "admin")`, subscribes to `adviser:events`, same framing/heartbeat/ + lifetime rules. +- New `app/views/events.py` — `EventMessage` Pydantic schema (`event_type: str, entity_id: str, + summary: str`) — validate every outgoing frame against it before writing to the stream; the pushed + event is a contract too, not just an internal detail. +- Register both blueprints in `app/__init__.py`. + +**6e. Ops config.** Edit `frontend/nginx.conf.template`: add `proxy_buffering off;` and an adequate +`proxy_read_timeout` (at least the stream's ~30-minute max lifetime) for the `/api/v1/events/stream` +and `/api/v1/admin/events/stream` locations specifically — do not change buffering/timeout for any +other path. + +**Tests:** +- `tests/integration/test_event_publisher.py` (new, using `FakeEventBus`) — channel-name and + message-shape construction for all three publisher methods; publish-after-commit ordering (drive + a real fill/verdict/break through the actual service with a `FakeEventBus` injected, assert the + bus received nothing before the commit and exactly one correctly-shaped message after; assert a + rolled-back transaction publishes nothing at all). +- `tests/api/test_events_stream.py` (new) — 401 for an anonymous request to either stream; a + customer session can only ever receive messages published to their own `customer::events` + channel, never another customer's (drive two sessions, publish to each, assert cross-contamination + never happens); `@requires_role` enforcement on the admin stream (customer session → 403); a + heartbeat frame is emitted within the test's configured short timeout (make the heartbeat interval + test-configurable rather than waiting 20 real seconds in the test). + +**Report:** full `uv run pytest` output; `uv run lint-imports` output showing the new contract entry +passes; explicitly confirm you did not modify `reconciliation_service.py`. + +--- + +## Task 7: Frontend — App shell rewrite (5-destination nav, providers, route restructure) + +This is the highest-risk frontend task because it is the one place a mistake breaks Chat, which is +explicitly out of scope for change. + +**7a. `EventStreamContext`.** New `src/services/eventStream.js` — pure infra (same category as +`apiClient.js`/`mockClient.js`, knows nothing about orders/KYC/breaks): a singleton wrapping +`new EventSource('/api/v1/events/stream')` (or the admin path — parameterize it, since Task 12 needs +the admin variant), a `Map>`, `connect()`/`disconnect()`, +`subscribe(eventTypes: string[], listener) -> unsubscribeFn`, exponential-backoff reconnect on the +`EventSource`'s native `onerror` (`EventSource` auto-reconnects on its own by default — add backoff +only for the repeated-immediate-failure case, don't fight the browser's own retry). New +`src/contexts/EventStreamContext.jsx` — `EventStreamProvider({children})` calls `connect()` when +`useSession().status === 'authenticated'` and `disconnect()` on logout or unmount; exposes +`{subscribe, connectionStatus}` via `useEventStream()`. + +**7b. `useLiveRefetch` hook.** New `src/hooks/useLiveRefetch.js`: +```js +export function useLiveRefetch(eventTypes, refetch) { + const { subscribe } = useEventStream() + useEffect(() => subscribe(eventTypes, refetch), [subscribe, eventTypes, refetch]) +} +``` +Do not call any hook that uses this yet in this task — Tasks 8-12 add the one-line calls in their +own hooks. This task only builds and tests the primitive itself (subscribe/unsubscribe symmetry: no +listener leak after unmount; a matching event type calls the listener exactly once; a non-matching +event type never calls it). + +**7c. Route restructure in `src/App.jsx`.** Five customer destinations replace the current ten +routes: `/dashboard`(from Task 8), `/invest` (Task 9), `/money` (Task 10), `/account` (Task 11), +`/chat` (untouched). Add a redirect from every old path (`/portfolio`, `/orders*`, `/lots`, +`/funding`, `/fees*`, `/statements*`) to its new home so existing bookmarks/links don't 404 — use +`` for each. **Critical guard placement, do not get this wrong**: `/onboarding` +stays a **sibling route outside `RequireOnboarded`**, guarded only by `RequireRole +roles={['customer']}` — exactly as today. The new `/account` destination bundles onboarding +*content* (Task 11), but the *route* `/account` sits inside `RequireOnboarded` while a *separate* +`/onboarding` route remains outside it. Nesting `/onboarding` inside `RequireOnboarded` is an +infinite redirect loop: the guard fails closed → redirects to `/onboarding` → still inside the guard +→ fails again. If you are unsure whether a given route is inside or outside `RequireOnboarded`, +trace it explicitly in your own report rather than guessing. + +**7d. `AppLayout.jsx`/`AppLayout.css` rewrite.** Five nav links (Dashboard/Invest/Money/Account/Ask +Trueup) replacing the current grouped-links structure. **Keep verbatim** (do not rewrite, just +relocate/reuse as needed): `useDismissablePanel`, `getFocusable`, `initialsFromEmail`, +`AccountMenu`, `NavDrawer`. Mount `` and `` (from Task 1) here, +wrapping the router's children, inside `SessionProvider`. Add the `ThemeToggle` (Task 1) into the +header's right-hand cluster, next to `AccountMenu`. + +**The one property that must survive exactly**, on whichever element now plays the role +`.tu-app-layout__main` plays today: `display:flex; flex-direction:column; flex:1; min-height:0`. Any +new wrapper `
` you introduce around `` must carry these four properties, or the Chat +page's transcript stops scrolling internally and instead the whole page scrolls (see Global +Constraints). **Write one Vitest test** — render the shell, find the main content element, assert +those four computed/inline style properties — specifically so a future change to this file cannot +silently regress Chat without a test failing. + +**Report:** full `npm test` output including the new shell layout-contract test; `npm run build`; +manually trace and state in the report which route is inside vs. outside `RequireOnboarded` for +every one of the 5 destinations plus `/onboarding`, `/login`, `/register`. + +--- + +## Task 8: Frontend — Dashboard destination + +Depends on Tasks 1, 5, 6 (via 7's plumbing), 7. Read `docs/specs/frontend/design-system.md` for the +full visual spec (palette/type/component rules) — this task is a visual build, follow it exactly +rather than re-deriving a design; the approved mockup already exists as a reference for shape and +copy, ask the controller for its location if not provided in the dispatch. + +**Hooks** (new, each following the Global Constraints canonical shape): +- `src/features/portfolio/hooks/useHoldings.js` — `GET /portfolios/holdings`. A 404 with + `code: "no_model_assigned"` is not an error state — map it to a distinct `status: 'unassigned'` + (not `'error'`) so the component renders `AssignmentPrompt` (existing component), never + `ErrorState`, in that case. +- `src/features/portfolio/hooks/usePerformance.js` — `GET /portfolios/performance?range=`, `range` + param one of `1m|3m|6m|1y|all` (backend's exact enum — confirm casing against + `app/controllers/api/portfolios.py` before hardcoding). +- `src/features/funding/hooks/useCashSummary.js` — `GET /funding/cash-summary` (split out of the + existing `useFunding.js`; leave `useFunding.js`'s deposit/withdraw/history responsibilities alone + for Task 10 to finish restructuring — this task only extracts the cash-summary read). +- `src/features/watchlist/{api/watchlistApi.js,hooks/useWatchlist.js}` (new feature dir) — + membership persisted to `localStorage` key `trueup.watchlist.v1` (array of `security_id`), every + read/write wrapped in try/catch so a private-mode/storage-disabled browser renders the empty state + rather than throwing. Prices come from the real `GET /api/v1/securities` (Task 2) — no mock, no + simulated PRNG price generator; the `source: "simulated"` field from that endpoint is what drives + the existing `SimulatedBadge`, shown once in the panel header, not per-row. +- `src/features/securities/{api/securitiesApi.js,hooks/useSecurities.js}` — moves from + `src/features/portfolio/hooks/useSecurities.js` (delete the old file, update every import) and + switches from deriving the universe out of `/portfolios/models` target weights to calling + `GET /api/v1/securities` directly. +- `src/features/valuation/hooks/useValuationHistory.js` — already built in Task 5; this task is + its first real UI consumer via a ledger band. + +**Components** (new): +- `src/components/ValuationChart/{ValuationChart.jsx,.css,index.js}` — hand-rolled SVG line chart, + no charting library (none exists in `package.json` and one chart doesn't justify adding one). + `viewBox` scaled to the data, `preserveAspectRatio="none"`, `vector-effect: non-scaling-stroke` on + the path so it stays crisp at any width. Pointer-move scrub updates a controlled "readout" (the + hero balance figure becomes a live readout of the scrubbed day, per the approved mockup). + Keyboard: the chart container is focusable, arrow keys step the scrub cursor one point at a time, + an `aria-live="polite"` region announces the scrubbed date and value. `role="img"` with an + `aria-label` summarizing the series. A trailing segment where `is_provisional` is renders visually + distinct (dashed). Fewer than 2 points renders `EmptyState` inside the plot area, never a broken + or empty ``. Load-in animation (a single `stroke-dashoffset` draw, ~700ms) is skipped entirely + under `prefers-reduced-motion: reduce`. +- `src/components/DriftMeter/{DriftMeter.jsx,.css,index.js}` — a horizontal track, fill to + `current_weight_pct`, a tick at `target_weight_pct`, the signed drift printed as text (e.g. + `+1.7pp`), a distinct visual (not just color) treatment when `is_flagged` is true. A holding with + no target (the CASH line, `security_id: null`) renders a value only, no meter — do not force a + meter onto a row that has nothing to compare against. +- `src/features/watchlist/components/WatchlistPanel.jsx` (+ `.css`) — add-symbol control sourced + from `useSecurities`, per-row last price + day-over-day change + a small sparkline (a second, + smaller instance of the same SVG approach as `ValuationChart`, or a shared sub-component if that's + cleaner — your call, document which you chose), a remove control per row, one `SimulatedBadge` in + the panel header (not per-row). +- A ledger band component (name it `LedgerBand` or similar — your call) rendering + `useValuationHistory`'s paginated entries with an expandable row showing effective-vs-recorded + date and memo (`docs/specs/frontend/design-system.md`'s bitemporal-visibility intent), plus the + "Load more" control from Task 5's contract. + +**Wire live updates**: `useHoldings`/`useCashSummary`/`useBalance` (existing) each get one added +line — `useLiveRefetch(['order_updated'], refetch)` — since a fill moves both cash and holdings. Do +not build any other live-update mechanism; this is the entire integration surface with Task 6/7's +infra. + +**`src/pages/DashboardPage.jsx` (+ `.css`) rewrite** composing all of the above into the approved +layout (hero plate, cash pair at equal visual weight per ADR 5 — same size, same text color, same +treatment for withdrawable and investable, never one visually subordinate to the other — holdings + +watchlist side by side, ledger band below). + +**Tests**: component tests for `ValuationChart` (renders N points; scrub emits the correct index on +pointer move and on arrow-key nav; empty/single-point degenerate states render `EmptyState`, not a +broken chart), `DriftMeter` (sign handling, `is_flagged` visual state, no-target CASH case), +`WatchlistPanel` (add/remove round-trips through `localStorage`, renders correctly when +`localStorage` is unavailable), the ledger band (expand/collapse, "Load more"), and `useHoldings`'s +`unassigned` vs `error` vs `loaded` branching. + +**Report:** full `npm test` output; `npm run build`; confirm the dashboard renders correctly against +the real backend from Tasks 2/4/6 running locally (not solely against mocked `apiClient` calls in +tests) — describe what you actually ran to verify this. + +--- + +## Task 9: Frontend — Invest destination (portfolio + orders + lots + lot-picker) + +Depends on Tasks 3, 5, 7, 8 (reuses `useHoldings`/`DriftMeter` from Task 8 — do not duplicate them). + +- Fold `PortfolioPage.jsx` and `LotsPage.jsx`'s content into a new `src/pages/InvestPage.jsx` + (`+.css`); delete `src/pages/OrdersPage.jsx` (18 lines of pure chrome per the earlier inventory — + inline its header + "New order" button directly into `InvestPage`, don't preserve it as a + sub-component with no purpose). `OrderNewPage.jsx`/`OrderDetailPage.jsx` are restyle-only — keep + their existing hook usage and logic, change only their chrome/wrapper to match the new page. +- `useOrders`/`useOrder` (from Task 5's pagination work) each get one added line: + `useLiveRefetch(['order_updated'], refetch)`. +- `usePlaceOrder` (existing) gains a `lotIds` param threaded through to the `POST /orders` body's + `lot_ids` field (Task 3's contract) — only meaningful for `side === 'sell'`. +- New `src/features/orders/components/LotPicker.jsx` (+`.css`) and + `src/features/orders/hooks/useLotPicker.js` — shown **only** when the order form's side is + `'sell'`, sourced from `useLots` filtered to the selected security's lots with + `quantity_remaining > 0`. Shows each candidate lot's `quantity_remaining`, `acquired_at`, and + current unrealized gain/loss (already on `GET /lots`'s response — do not re-derive it); lets the + customer select one or more lots; computes and displays the selected total against the requested + quantity, disabling order submission when the selection under-covers the requested quantity + (mirroring Task 3's placement-time validation client-side, as a UX nicety — **the server-side + check in Task 3 remains the actual authority**, do not treat this client check as sufficient on + its own). Omitting a selection entirely means FIFO — make this the default/undo-able state, not a + forced choice. +- `OrderResponse.designated_lot_ids` (Task 3) renders on the order detail view — which lots (if any) + were named for a sell order. +- The existing wash-sale field (`wash_sale_disallowed` on `GET /lots`, already returned by the + backend today and currently ignored by the UI) gets a visible treatment in the lot table — it is + always present (Money, `"0.00"` when nothing was disallowed), so render it as a column or inline + note rather than a conditional badge that only sometimes appears. + +**Tests**: `LotPicker` (renders only for sell; selection math; disables submit when short; +FIFO-default/no-selection state); order-detail rendering of `designated_lot_ids`; the wash-sale +column's presence and formatting. + +**Report:** full `npm test`, `npm run build`; confirm a real sell order with a lot designation +placed against the Task 3 backend actually round-trips (place it, fetch the order, confirm +`designated_lot_ids` matches what was selected). + +--- + +## Task 10: Frontend — Money destination (funding + fees, bank re-link) + +Depends on Tasks 5, 7, 8 (finishes the `useFunding.js` split Task 8 started). + +- Fold `FundingPage.jsx` and `FeesPage.jsx` into a new `src/pages/MoneyPage.jsx` (`+.css`). +- Finish splitting `src/features/funding/hooks/useFunding.js`. `useCashSummary` (Task 8) and + `useFundingHistory` (Task 5 — do not recreate it, it already exists with pagination built in + against the `/funding/history` contract; this task is simply its first real page consumer) have + both already been created as separate files. Your job here is only to finish removing + cash-summary/history reading from the old `useFunding.js` so nothing duplicates them, and to make + `MoneyPage` consume the two new hooks directly — `useFunding.js` either becomes a thin + backward-compat re-export or is deleted with every remaining import updated; pick one and do it + completely, don't leave both the old and new hooks live and half-used. +- `useCurrentBankLink` (existing) — handle the `requires_reauth` status (already modeled + server-side, per FR-43, and currently never handled client-side): render a distinct interstitial + ("re-link your bank" prompt) instead of treating it the same as `active`, and **pause** (not + silently retry or fail) any in-flight deposit/withdraw flow that depends on the link, matching + FR-43's "never silently fail or retry" requirement exactly. +- Cash pair (withdrawable/investable) at equal visual weight on the Money overview per ADR 5, same + as Task 8's dashboard treatment — reuse the same presentational component rather than rebuilding + it twice; if Task 8 didn't already extract one, extract it now and have Task 8's dashboard also + use it (a small backport, keep it minimal). +- Deposit/withdraw as a segmented control over one form (existing `DepositForm`/`WithdrawForm` logic + preserved, presentation restyled). + +**Tests**: the bank-link component's three states (`active`/`requires_reauth`/no link) including the +paused-flow behavior; the finished `useFundingHistory` hook's "Load more" UI in its real page +context (not just the isolated Task 5 test). + +**Report:** full `npm test`, `npm run build`. + +--- + +## Task 11: Frontend — Account destination (profile, both identity gates, statements) + +Depends on Tasks 5, 7. + +Create a new `src/pages/AccountPage.jsx` (`+.css`) composing profile, both identity gates, bank +link, and statements into one destination — the same fold pattern Task 9 used for Invest and Task +10 used for Money. Delete `src/pages/StatementsPage.jsx` (11 lines of pure chrome per the earlier +inventory — inline `StatementList` directly into `AccountPage`, don't preserve it as a +purposeless wrapper). `src/pages/OnboardingPage.jsx` stays its own component (restyle only, see +below) but is surfaced from within `AccountPage` for an onboarded-in-progress customer, consistent +with Task 7's route trace: the `/account` *route* sits inside `RequireOnboarded`, while the +separate `/onboarding` *route* stays outside it — `AccountPage` itself is only reachable once +onboarded, so it links out to `/onboarding` rather than embedding its steps inline. + +- New `src/features/profile/{api/profileApi.js,hooks/useProfile.js,hooks/useUpdateProfile.js, + components/ProfileForm.jsx}` — **`GET`/`PATCH /profile` has no frontend feature at all today**, + this is entirely net-new. `PATCH` is partial-update: omitting a field leaves it unchanged, sending + `null` explicitly clears it — the form must distinguish "user didn't touch this field" from "user + cleared this field" (e.g. only include a field in the PATCH body if it was actually edited). + Render `null` values as "Not set", never as a blank/empty-looking field that could be mistaken for + a loading state. +- `useIdentityStatus` (existing, rewrite) — surface **both** `kyc_status` and + `account_approval_status` as two independent, separately-labeled indicators (today only one is + shown) — reference `docs/specs/frontend/design-system.md`'s treatment of this if it specifies one + before inventing your own. Add `useLiveRefetch(['identity_status_changed'], refetch)`. **Bonus fix + riding along, do it in this task**: `RequireOnboarded` (in `src/routes/guards.jsx`) currently calls + `/identity/status` (rate-limited 30/min) on every navigation. Change it to read from + `SessionContext`'s cached identity status (populated once, refreshed only by the live event above) + rather than re-fetching on every route change. Confirm `/onboarding`'s reachability is unaffected + by this change (retest Task 7's guard trace). +- `OnboardingPage.jsx` — restyle only, per the plan; its `kycStepStatus`/`bankStepStatus` + cooldown-aware logic is correct, keep it; its hardcoded "You're all set" CTA link target must be + updated from the old `/portfolio` path to the new `/invest` path (Task 9). +- `useStatements` (Task 5's pagination) — first real page consumer. The restated-figure treatment + (FR-26: both the as-published and as-corrected figures visible, the original struck through and + labeled, never silently replaced) needs to move from today's separate-view toggle to a + side-by-side presentation on the statements list itself — check `docs/specs/frontend/design-system.md` + §8.1 if present for the exact prescribed treatment before designing your own. +- `StatementDetailPage.jsx` — restyle only. + +**Tests**: `ProfileForm` (partial-update field-touched tracking, null-clears-field, "Not set" +rendering); the two-gate identity display; the restated-statement side-by-side presentation; the +guard's cached-vs-live-refresh behavior (mock the live event, assert no extra `/identity/status` +call fires on a plain route navigation). + +**Report:** full `npm test`, `npm run build`; confirm `/onboarding` is still reachable pre-approval +by tracing the route guard change end-to-end (state exactly what you traced). + +--- + +## Task 12: Frontend — Admin rewiring (mock → real endpoints, live breaks) + +Depends on Task 6 (for the live-breaks subscription) and Task 7 (for `eventStream.js`'s admin +variant). Independent of Tasks 8-11 — can run any time after 6/7, included last here only because +it's the lowest-priority remaining task, not because anything blocks it earlier. + +- `src/features/admin-customers/api/adminCustomersApi.js` — currently backed entirely by + `mockClient` despite the real endpoints (`GET /admin/customers`, `GET /admin/customers/`, + `GET /admin/customers//fees`, `POST /admin/kyc-overrides/`) already existing and working. + Rewire every method to `apiClient` against the real paths. `GET /admin/customers` is already + paginated server-side (per `docs/specs/08-surfaces.md` §4) — give it the same "Load more" hook + shape as Task 5's other four endpoints rather than inventing a different pagination UI for this + one. Delete the mock `seed()` factory and its namespace once nothing references it. +- `useBreaks` (existing, admin) gets `useLiveRefetch(['break_opened'], refetch)`, subscribing via + `eventStream.js`'s admin-stream variant (`/api/v1/admin/events/stream`). +- Confirm `src/services/mockClient.js` itself is **not deleted** — it stays as documented pure + infra even with its last live consumer gone (per the approved plan's explicit note on this). + +**Tests**: `adminCustomersApi.js`'s calls now hit `apiClient` (assert via a mocked `fetch`, not +`mockClient`); `useBreaks`'s live-refetch subscription. + +**Report:** full `npm test`, `npm run build`; grep confirmation that `mockClient` has zero remaining +feature-level consumers but the file itself is untouched. + +--- + +## Verification (after all 12 tasks, before finishing the branch) + +- `docker compose up -d && cd backend && uv run pytest && uv run mypy --strict app && uv run ruff + check app tests && uv run lint-imports && uv run alembic upgrade head`. +- `cd frontend && npm run lint && npm run build && npm test`. +- Manual walkthrough: place a sell order with a specific lot designation, confirm the resulting + lot's `designation` becomes `specific`; open two browser sessions, fill an order in one (or + trigger the paper-trading webhook manually), confirm the other session's order list updates within + a couple seconds without a manual refresh; page past 50 rows via "Load more" on orders, lots, + funding history, valuation history, and statements; kill network mid-SSE-session and confirm + `EventSource` reconnects on its own; confirm `/onboarding` stays reachable with KYC pending (no + redirect loop); confirm the Chat page's transcript still scrolls internally, not the whole page; + toggle the theme through light/dark/system and confirm it persists across a reload. diff --git a/frontend/src/features/funding/components/FundingHistoryTable.jsx b/frontend/src/features/funding/components/FundingHistoryTable.jsx index 58bea0f..2d3c783 100644 --- a/frontend/src/features/funding/components/FundingHistoryTable.jsx +++ b/frontend/src/features/funding/components/FundingHistoryTable.jsx @@ -1,5 +1,8 @@ +import { Button } from '../../../components/Button' import { EmptyState } from '../../../components/EmptyState' +import { ErrorState } from '../../../components/ErrorState' import { Table } from '../../../components/Table' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { formatDate, formatMoney } from '../../../utils/format.js' import { formatFailureReason, hasFundingActivity } from '../utils/fundingSummary.js' import { SettlementStatusPill } from './SettlementStatusPill.jsx' @@ -8,48 +11,66 @@ import './FundingHistory.css' const ENTRY_LABEL = { deposit: 'Deposit', withdrawal: 'Withdrawal' } /** - * Presentational — `entries` arrives already sorted from `FundingPage.jsx`; `sort`/`onSort` wire + * Presentational — `entries` arrives already sorted from `MoneyPage.jsx`; `sort`/`onSort` wire * straight into the Date header's own `sortable` prop (structure.md's convention, matching * `LotTable`). Row key is the real `journal_entry_id`, never an array index. A failed row shows * `formatFailureReason(...)` in place of its date — the one piece of information that row is * actually about. Money is never demoted for a withdrawal: same `--color-text` weight as a * deposit (no gain/loss-style coloring here, unlike `LotTable.css`) — the sign and the Type column - * carry the distinction, not color (design-system §2.4). + * carry the distinction, not color (design-system §2.4). `hasMore`/`isLoadingMore`/`loadMoreError`/ + * `onLoadMore` wire `useFundingHistory()`'s pagination straight through, matching `LotTable`. */ -export function FundingHistoryTable({ entries, sort, onSort }) { +export function FundingHistoryTable({ entries, sort, onSort, hasMore, isLoadingMore, loadMoreError, onLoadMore }) { if (!hasFundingActivity(entries)) { return } return ( - - - onSort('date')}> - Date - - Type - Amount - Status - Expected settlement - - - {entries.map((entry) => { - const isFailed = entry.settlement_status === 'failed' - return ( - - {isFailed ? formatFailureReason(entry.failure_reason) : formatDate(entry.effective_date)} - {ENTRY_LABEL[entry.entry_type] ?? entry.entry_type} - - {formatMoney(entry.amount)} - - - - - {entry.expected_settlement_date ? formatDate(entry.expected_settlement_date) : '—'} - - ) - })} - -
+
+ + + onSort('date')}> + Date + + Type + Amount + Status + Expected settlement + + + {entries.map((entry) => { + const isFailed = entry.settlement_status === 'failed' + return ( + + {isFailed ? formatFailureReason(entry.failure_reason) : formatDate(entry.effective_date)} + {ENTRY_LABEL[entry.entry_type] ?? entry.entry_type} + + {formatMoney(entry.amount)} + + + + + {entry.expected_settlement_date ? formatDate(entry.expected_settlement_date) : '—'} + + ) + })} + +
+ {loadMoreError ? ( + + ) : ( + hasMore && ( + + ) + )} +
) } diff --git a/frontend/src/features/funding/hooks/useFundingHistory.js b/frontend/src/features/funding/hooks/useFundingHistory.js new file mode 100644 index 0000000..42df24e --- /dev/null +++ b/frontend/src/features/funding/hooks/useFundingHistory.js @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from 'react' +import { fundingApi } from '../api/fundingApi.js' + +const IDLE = { status: 'idle', entries: [], nextCursor: null, error: null } +// status: 'idle' | 'loading' | 'loaded' | 'loading-more' | 'error' + +/** `GET /funding/history` (S2 §6) — split out of what was `useFunding.js`; that hook is gone now + * (Task 10 finished the split), its cash-summary half folded into `useCashSummary.js` instead. */ +export function useFundingHistory() { + const [state, setState] = useState(IDLE) + + const refetch = useCallback(async () => { + setState((prev) => ({ ...prev, status: 'loading', error: null })) + try { + const data = await fundingApi.getFundingHistory() + setState({ status: 'loaded', entries: data.entries, nextCursor: data.next_cursor, error: null }) + } catch (error) { + setState({ status: 'error', entries: [], nextCursor: null, error }) + } + }, []) + + const loadMore = useCallback(async () => { + if (state.nextCursor === null || state.status === 'loading-more') return + setState((prev) => ({ ...prev, status: 'loading-more' })) + try { + const data = await fundingApi.getFundingHistory({ after: state.nextCursor }) + setState((prev) => ({ + status: 'loaded', + entries: [...prev.entries, ...data.entries], + nextCursor: data.next_cursor, + error: null, + })) + } catch (error) { + setState((prev) => ({ ...prev, status: 'error', error })) + } + }, [state.nextCursor, state.status]) + + useEffect(() => { + refetch() + }, [refetch]) + + return { ...state, refetch, loadMore } +} diff --git a/frontend/src/features/lots/hooks/useLots.js b/frontend/src/features/lots/hooks/useLots.js index 3963a19..e72fae4 100644 --- a/frontend/src/features/lots/hooks/useLots.js +++ b/frontend/src/features/lots/hooks/useLots.js @@ -2,12 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { lotsApi } from '../api/lotsApi.js' import { filterLotsByStatus, sortLots } from '../utils/lotSummary.js' -const IDLE = { status: 'idle', lots: [], error: null } +const IDLE = { status: 'idle', lots: [], nextCursor: null, error: null } +// status: 'idle' | 'loading' | 'loaded' | 'loading-more' | 'error' const DEFAULT_SORT = { column: 'symbol', direction: 'asc' } -/** Owns the fetch plus the client-side filter/sort UI state (no server-side params exist for - * either — structure.md §5/§6) — `visibleLots` is the memoized, derived view; `lots` stays the raw - * fetched list for page-level totals that must reflect the whole account regardless of the filter. */ +/** Owns the fetch plus the client-side filter/sort UI state (no server-side sort/filter params + * exist — structure.md §5/§6, pagination is the only server-side list param) — `visibleLots` is + * the memoized, derived view; `lots` stays the raw fetched (and accumulated, across `loadMore`) + * list for page-level totals that must reflect the whole account regardless of the filter. */ export function useLots() { const [state, setState] = useState(IDLE) const [statusFilter, setStatusFilter] = useState('all') @@ -16,13 +18,29 @@ export function useLots() { const refetch = useCallback(async () => { setState((prev) => ({ ...prev, status: 'loading', error: null })) try { - const lots = await lotsApi.list() - setState({ status: 'loaded', lots, error: null }) + const data = await lotsApi.list() + setState({ status: 'loaded', lots: data.lots, nextCursor: data.next_cursor, error: null }) } catch (error) { - setState({ status: 'error', lots: [], error }) + setState({ status: 'error', lots: [], nextCursor: null, error }) } }, []) + const loadMore = useCallback(async () => { + if (state.nextCursor === null || state.status === 'loading-more') return + setState((prev) => ({ ...prev, status: 'loading-more' })) + try { + const data = await lotsApi.list({ after: state.nextCursor }) + setState((prev) => ({ + status: 'loaded', + lots: [...prev.lots, ...data.lots], + nextCursor: data.next_cursor, + error: null, + })) + } catch (error) { + setState((prev) => ({ ...prev, status: 'error', error })) + } + }, [state.nextCursor, state.status]) + useEffect(() => { refetch() }, [refetch]) @@ -39,5 +57,5 @@ export function useLots() { [state.lots, statusFilter, sort], ) - return { ...state, visibleLots, statusFilter, setStatusFilter, sort, toggleSort, refetch } + return { ...state, visibleLots, statusFilter, setStatusFilter, sort, toggleSort, refetch, loadMore } } diff --git a/frontend/src/features/orders/components/OrderList.jsx b/frontend/src/features/orders/components/OrderList.jsx index 9dbab3d..d092da3 100644 --- a/frontend/src/features/orders/components/OrderList.jsx +++ b/frontend/src/features/orders/components/OrderList.jsx @@ -9,7 +9,7 @@ import { getOrdersErrorMessage } from '../ordersErrorMessage.js' import { OrderRow } from './OrderRow.jsx' export function OrderList() { - const { status, orders, error, refetch } = useOrders() + const { status, orders, nextCursor, error, refetch, loadMore } = useOrders() const navigate = useNavigate() if (status === 'idle' || status === 'loading') { @@ -22,7 +22,7 @@ export function OrderList() { ) } - if (status === 'error') { + if (status === 'error' && orders.length === 0) { return } @@ -31,26 +31,37 @@ export function OrderList() { navigate('/orders/new')}>Place your first order} + action={} /> ) } return ( - - - Security - Side - Quantity - Avg. fill price - Status - Placed - - - {orders.map((order) => ( - navigate(`/orders/${order.id}`)} /> - ))} - -
+
+ + + Security + Side + Quantity + Avg. fill price + Status + Placed + + + {orders.map((order) => ( + navigate(`/invest/orders/${order.id}`)} /> + ))} + +
+ {status === 'error' ? ( + + ) : ( + nextCursor !== null && ( + + ) + )} +
) } diff --git a/frontend/src/features/orders/hooks/useOrders.js b/frontend/src/features/orders/hooks/useOrders.js index 52d3738..4cb4fab 100644 --- a/frontend/src/features/orders/hooks/useOrders.js +++ b/frontend/src/features/orders/hooks/useOrders.js @@ -1,7 +1,9 @@ import { useCallback, useEffect, useState } from 'react' +import { useLiveRefetch } from '../../../hooks/useLiveRefetch.js' import { ordersApi } from '../api/ordersApi.js' -const IDLE = { status: 'idle', orders: [], error: null } +const IDLE = { status: 'idle', orders: [], nextCursor: null, error: null } +// status: 'idle' | 'loading' | 'loaded' | 'loading-more' | 'error' export function useOrders() { const [state, setState] = useState(IDLE) @@ -10,15 +12,33 @@ export function useOrders() { setState((prev) => ({ ...prev, status: 'loading', error: null })) try { const data = await ordersApi.list() - setState({ status: 'loaded', orders: data.orders, error: null }) + setState({ status: 'loaded', orders: data.orders, nextCursor: data.next_cursor, error: null }) } catch (error) { - setState({ status: 'error', orders: [], error }) + setState({ status: 'error', orders: [], nextCursor: null, error }) } }, []) + const loadMore = useCallback(async () => { + if (state.nextCursor === null || state.status === 'loading-more') return + setState((prev) => ({ ...prev, status: 'loading-more' })) + try { + const data = await ordersApi.list({ after: state.nextCursor }) + setState((prev) => ({ + status: 'loaded', + orders: [...prev.orders, ...data.orders], + nextCursor: data.next_cursor, + error: null, + })) + } catch (error) { + setState((prev) => ({ ...prev, status: 'error', error })) + } + }, [state.nextCursor, state.status]) + + useLiveRefetch(['order_updated'], refetch) + useEffect(() => { refetch() }, [refetch]) - return { ...state, refetch } + return { ...state, refetch, loadMore } } diff --git a/frontend/src/features/statements/hooks/useStatements.js b/frontend/src/features/statements/hooks/useStatements.js index 69ac489..145897e 100644 --- a/frontend/src/features/statements/hooks/useStatements.js +++ b/frontend/src/features/statements/hooks/useStatements.js @@ -1,9 +1,13 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { statementsApi } from '../api/statementsApi.js' import { groupStatementsByPeriod } from '../groupStatementsByPeriod.js' -const IDLE = { status: 'idle', periods: [], error: null } +const IDLE = { status: 'idle', statements: [], nextCursor: null, error: null } +// status: 'idle' | 'loading' | 'loaded' | 'loading-more' | 'error' +/** `periods` is derived from the raw, accumulated `statements` list on every render (grouping is + * cheap and must re-run over the whole list — not just the newest page — since `loadMore` can + * append another version of a period already on an earlier page). */ export function useStatements() { const [state, setState] = useState(IDLE) @@ -11,15 +15,33 @@ export function useStatements() { setState((prev) => ({ ...prev, status: 'loading', error: null })) try { const data = await statementsApi.list() - setState({ status: 'loaded', periods: groupStatementsByPeriod(data.statements), error: null }) + setState({ status: 'loaded', statements: data.statements, nextCursor: data.next_cursor, error: null }) } catch (error) { - setState({ status: 'error', periods: [], error }) + setState({ status: 'error', statements: [], nextCursor: null, error }) } }, []) + const loadMore = useCallback(async () => { + if (state.nextCursor === null || state.status === 'loading-more') return + setState((prev) => ({ ...prev, status: 'loading-more' })) + try { + const data = await statementsApi.list({ after: state.nextCursor }) + setState((prev) => ({ + status: 'loaded', + statements: [...prev.statements, ...data.statements], + nextCursor: data.next_cursor, + error: null, + })) + } catch (error) { + setState((prev) => ({ ...prev, status: 'error', error })) + } + }, [state.nextCursor, state.status]) + useEffect(() => { refetch() }, [refetch]) - return { ...state, refetch } + const periods = useMemo(() => groupStatementsByPeriod(state.statements), [state.statements]) + + return { ...state, periods, refetch, loadMore } } diff --git a/frontend/src/features/valuation/api/valuationApi.js b/frontend/src/features/valuation/api/valuationApi.js index f03e5e1..ad8d4c7 100644 --- a/frontend/src/features/valuation/api/valuationApi.js +++ b/frontend/src/features/valuation/api/valuationApi.js @@ -1,7 +1,9 @@ import { apiClient } from '../../../services/apiClient.js' +import { paginationQuery } from '../../../utils/paginationQuery.js' export const valuationApi = { getBalance: () => apiClient.get('/valuation/balance'), getReturns: (periodStart, periodEnd) => apiClient.get(`/valuation/returns?period_start=${periodStart}&period_end=${periodEnd}`), + getHistory: (pagination) => apiClient.get(`/valuation/history${paginationQuery(pagination)}`), } diff --git a/frontend/src/features/valuation/hooks/useValuationHistory.js b/frontend/src/features/valuation/hooks/useValuationHistory.js new file mode 100644 index 0000000..8483261 --- /dev/null +++ b/frontend/src/features/valuation/hooks/useValuationHistory.js @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from 'react' +import { valuationApi } from '../api/valuationApi.js' + +const IDLE = { status: 'idle', entries: [], nextCursor: null, error: null } +// status: 'idle' | 'loading' | 'loaded' | 'loading-more' | 'error' + +/** `GET /valuation/history` (S4 §7/§9) — always the live/current variant (ADR 6); S6's + * as-published statement history is `useStatements.js`'s own hook, never this one. */ +export function useValuationHistory() { + const [state, setState] = useState(IDLE) + + const refetch = useCallback(async () => { + setState((prev) => ({ ...prev, status: 'loading', error: null })) + try { + const data = await valuationApi.getHistory() + setState({ status: 'loaded', entries: data.entries, nextCursor: data.next_cursor, error: null }) + } catch (error) { + setState({ status: 'error', entries: [], nextCursor: null, error }) + } + }, []) + + const loadMore = useCallback(async () => { + if (state.nextCursor === null || state.status === 'loading-more') return + setState((prev) => ({ ...prev, status: 'loading-more' })) + try { + const data = await valuationApi.getHistory({ after: state.nextCursor }) + setState((prev) => ({ + status: 'loaded', + entries: [...prev.entries, ...data.entries], + nextCursor: data.next_cursor, + error: null, + })) + } catch (error) { + setState((prev) => ({ ...prev, status: 'error', error })) + } + }, [state.nextCursor, state.status]) + + useEffect(() => { + refetch() + }, [refetch]) + + return { ...state, refetch, loadMore } +} diff --git a/frontend/src/utils/paginationQuery.js b/frontend/src/utils/paginationQuery.js new file mode 100644 index 0000000..217564a --- /dev/null +++ b/frontend/src/utils/paginationQuery.js @@ -0,0 +1,11 @@ +// Builds the `?cursor=&limit=` query string every paginated list endpoint accepts +// (backend/app/controllers/api/{orders,lots,funding,valuation,statements}.py). `after` is the +// opaque `next_cursor` a prior page returned; sent as the `cursor` param, the name the backend +// actually reads. +export function paginationQuery({ after, limit } = {}) { + const params = new URLSearchParams() + if (after) params.set('cursor', after) + if (limit) params.set('limit', limit) + const qs = params.toString() + return qs ? `?${qs}` : '' +} From ce14909ebf2b8b83bd067f29cbb2c2bcbe00ddbe Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Wed, 16 Sep 2026 12:42:10 -0700 Subject: [PATCH 07/14] feat(ops): real-time push over SSE + Redis Pub/Sub (NFR-18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6 of the fullstack-rebuild plan, built to docs/specs/12-production-operations.md §6. New `EventBusPort` protocol (app/integrations/ports.py) with a `RedisEventBus` adapter and a `FakeEventBus` test double, added to `.importlinter`'s services-use-ports-only contract alongside the existing alpaca/plaid/stripe/azure entries. `EventPublisher` (app/services/ops/event_publisher.py) is the only place a channel name or message shape is constructed: `order_updated`, `identity_status_changed` (kyc/account_approval gates publish independently), `break_opened` (always to `adviser:events`). Three publish sites, each strictly after the triggering commit, never inside it: `AlpacaTradeUpdateHandler.handle()` on fill/terminal-non-filled status, `stripe_identity.py::_apply_verdict` on each of its two commits, and `morning_reconciliation.py` once per opened break after the job's own commit -- `ReconciliationService` itself is untouched. New `GET /api/v1/events/stream` (self-scoped, customer session) and `GET /api/v1/admin/events/stream` (adviser/admin only), same SSE framing as chat.py's existing stream: `data: ` per message, a `: ping` comment frame every 20s of silence, closes after ~30 minutes so `EventSource` reconnects rather than pinning a worker forever. Every outgoing frame validates against a new `EventMessage` schema. The pushed payload is a pointer only (event_type/entity_id/summary) -- the client always re-fetches; a dropped connection is never data loss. Dockerfile switches gunicorn to `--worker-class gthread` (2 workers x 32 threads, 60s timeout): a sync worker's arbiter heartbeat only refreshes between requests, so it would SIGKILL a 30-minute SSE stream regardless of --timeout, taking any other in-flight request on that worker down with it. nginx.conf.template disables proxy buffering and extends the read timeout for the two stream paths specifically. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p --- backend/.importlinter | 1 + backend/Dockerfile | 13 +++- backend/app/__init__.py | 4 + backend/app/controllers/admin/events.py | 56 ++++++++++++++ backend/app/controllers/api/events.py | 74 ++++++++++++++++++ .../controllers/webhooks/stripe_identity.py | 23 +++++- .../app/integrations/fake/fake_event_bus.py | 40 ++++++++++ backend/app/integrations/ports.py | 17 +++++ backend/app/integrations/redis/__init__.py | 0 backend/app/integrations/redis/event_bus.py | 55 ++++++++++++++ backend/app/jobs/__init__.py | 12 ++- backend/app/jobs/morning_reconciliation.py | 13 +++- backend/app/services/ops/event_publisher.py | 76 +++++++++++++++++++ .../services/orders/trade_update_handler.py | 23 +++++- backend/app/views/events.py | 18 +++++ .../test_alpaca_trade_update_handler.py | 7 +- .../integration/test_order_cancellation.py | 7 +- frontend/nginx.conf.template | 28 +++++++ 18 files changed, 459 insertions(+), 8 deletions(-) create mode 100644 backend/app/controllers/admin/events.py create mode 100644 backend/app/controllers/api/events.py create mode 100644 backend/app/integrations/fake/fake_event_bus.py create mode 100644 backend/app/integrations/redis/__init__.py create mode 100644 backend/app/integrations/redis/event_bus.py create mode 100644 backend/app/services/ops/event_publisher.py create mode 100644 backend/app/views/events.py diff --git a/backend/.importlinter b/backend/.importlinter index 5639d44..0e84e5f 100644 --- a/backend/.importlinter +++ b/backend/.importlinter @@ -42,6 +42,7 @@ forbidden_modules = app.integrations.azure app.integrations.marketdata app.integrations.fake + app.integrations.redis [importlinter:contract:models-know-no-business-rules] name = models never import services or controllers (S0 §3) diff --git a/backend/Dockerfile b/backend/Dockerfile index 2cdb821..4a83226 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -47,4 +47,15 @@ EXPOSE 8000 # Migrations run as a separate CD step (never at container start -- a crash-looping web replica # must never race a migration against itself). Gunicorn, not Flask's dev server, serves requests. -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--timeout", "30", "wsgi:app"] +# +# gthread, not the default sync worker (S12 S6, NFR-18): a sync worker's arbiter heartbeat is +# only refreshed between requests, never while a response body is being written, so a long-lived +# SSE stream (GET /api/v1/events/stream, up to 30 minutes) would be SIGKILLed by murder_workers() +# at --timeout regardless of its value -- taking any other in-flight request on that worker with +# it. gthread's own run loop calls notify() independently of request threads, so it survives long +# streams; 2 workers x 32 threads gives 64 concurrent slots on 0.5 vCPU / 1Gi, each SSE connection +# pinning one thread blocked on Redis I/O (never CPU, never the DB pool -- the SSE view opens no +# UnitOfWork). +CMD ["gunicorn", "--bind", "0.0.0.0:8000", \ + "--worker-class", "gthread", "--workers", "2", "--threads", "32", \ + "--timeout", "60", "wsgi:app"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py index d55fd88..26cfc27 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -11,12 +11,14 @@ from app.config import Settings, get_settings from app.controllers.admin.agent_requests import agent_requests_bp from app.controllers.admin.customers import admin_customers_bp +from app.controllers.admin.events import admin_events_bp from app.controllers.admin.kyc_overrides import admin_kyc_overrides_bp from app.controllers.admin.rebalance import admin_rebalance_bp from app.controllers.admin.staff_api_tokens import staff_api_tokens_bp from app.controllers.api.auth import auth_bp, init_auth from app.controllers.api.breaks import breaks_bp from app.controllers.api.chat import chat_bp +from app.controllers.api.events import events_bp from app.controllers.api.fees import fees_bp from app.controllers.api.funding import funding_bp from app.controllers.api.identity import identity_bp @@ -104,6 +106,8 @@ def create_app(settings: Settings | None = None) -> Flask: app.register_blueprint(funding_bp) app.register_blueprint(breaks_bp) app.register_blueprint(chat_bp) + app.register_blueprint(events_bp) + app.register_blueprint(admin_events_bp) app.register_blueprint(portfolios_bp) app.register_blueprint(profile_bp) app.register_blueprint(securities_bp) diff --git a/backend/app/controllers/admin/events.py b/backend/app/controllers/admin/events.py new file mode 100644 index 0000000..9b2c703 --- /dev/null +++ b/backend/app/controllers/admin/events.py @@ -0,0 +1,56 @@ +"""Adviser/admin real-time push (S12 §6, NFR-18): a single SSE stream over `adviser:events`, +carrying newly-opened reconciliation breaks. Staff-only (`@requires_role`); a dropped/never-open +connection is not data loss -- see `app/controllers/api/events.py`'s module docstring. +""" + +from __future__ import annotations + +import json +import time +from typing import TYPE_CHECKING, Any + +from flask import Blueprint, Response, stream_with_context + +from app.config import get_settings +from app.core.security import requires_role +from app.extensions import limiter +from app.integrations.redis.event_bus import RedisEventBus +from app.services.ops.event_publisher import ADVISER_CHANNEL +from app.views.events import EventMessage + +if TYPE_CHECKING: + from collections.abc import Iterator + +admin_events_bp = Blueprint("admin_events", __name__, url_prefix="/api/v1/admin/events") + +_HEARTBEAT_EVENT_TYPE = "heartbeat" +_MAX_STREAM_LIFETIME_SECONDS = 30 * 60 +"""Hard cap so a single SSE connection never holds a worker forever -- `EventSource` reconnects.""" + +_STAFF_ROLES = ("adviser", "admin") + + +def _generate() -> Iterator[str]: + subscription = RedisEventBus(get_settings().redis_url).subscribe(ADVISER_CHANNEL) + started = time.monotonic() + try: + for message in subscription: + if message.get("event_type") == _HEARTBEAT_EVENT_TYPE: + yield ": ping\n\n" + else: + event = EventMessage.model_validate(dict(message)) + yield f"data: {json.dumps(event.model_dump(mode='json'))}\n\n" + if time.monotonic() - started >= _MAX_STREAM_LIFETIME_SECONDS: + return + finally: + subscription.close() + + +@admin_events_bp.route("/stream", methods=["GET"]) +@limiter.limit("10 per minute") +@requires_role(*_STAFF_ROLES) +def stream_admin_events() -> Any: + return Response(stream_with_context(_generate()), mimetype="text/event-stream") + + +__all__ = ["admin_events_bp"] diff --git a/backend/app/controllers/api/events.py b/backend/app/controllers/api/events.py new file mode 100644 index 0000000..0a305cc --- /dev/null +++ b/backend/app/controllers/api/events.py @@ -0,0 +1,74 @@ +"""Customer real-time push (S12 §6, NFR-18): a single self-scoped SSE stream over +`customer::events`. A dropped/never-open connection is not data loss -- nothing published +over Pub/Sub is ever the sole record of a fact; the next plain `GET` still returns truth. + +`_current_customer_id` reads `flask.session["_user_id"]`, not `current_user.id`, to avoid a +`DetachedInstanceError` (`chat.py`/`profile.py`'s precedent). +""" + +from __future__ import annotations + +import json +import time +import uuid +from typing import TYPE_CHECKING, Any + +from flask import Blueprint, Response, stream_with_context +from flask import session as flask_session +from flask_login import current_user + +from app.config import get_settings +from app.core.errors import ForbiddenError, UnauthenticatedError +from app.extensions import limiter +from app.integrations.redis.event_bus import RedisEventBus +from app.services.ops.event_publisher import customer_channel +from app.views.events import EventMessage + +if TYPE_CHECKING: + from collections.abc import Iterator + +events_bp = Blueprint("events", __name__, url_prefix="/api/v1/events") + +_HEARTBEAT_EVENT_TYPE = "heartbeat" +_MAX_STREAM_LIFETIME_SECONDS = 30 * 60 +"""Hard cap so a single SSE connection never holds a worker forever -- `EventSource` reconnects.""" + + +def _current_customer_id() -> uuid.UUID: + if not current_user.is_authenticated: + raise UnauthenticatedError("Authentication required") + if current_user.role != "customer": + raise ForbiddenError("Real-time events are available to customer accounts only") + raw_user_id = flask_session.get("_user_id") + if not raw_user_id: + raise UnauthenticatedError("No authenticated session") + return uuid.UUID(raw_user_id) + + +def _generate(channel: str) -> Iterator[str]: + subscription = RedisEventBus(get_settings().redis_url).subscribe(channel) + started = time.monotonic() + try: + for message in subscription: + if message.get("event_type") == _HEARTBEAT_EVENT_TYPE: + yield ": ping\n\n" + else: + event = EventMessage.model_validate(dict(message)) + yield f"data: {json.dumps(event.model_dump(mode='json'))}\n\n" + if time.monotonic() - started >= _MAX_STREAM_LIFETIME_SECONDS: + return + finally: + subscription.close() + + +@events_bp.route("/stream", methods=["GET"]) +@limiter.limit("10 per minute") +def stream_events() -> Any: + customer_id = _current_customer_id() + return Response( + stream_with_context(_generate(customer_channel(str(customer_id)))), + mimetype="text/event-stream", + ) + + +__all__ = ["events_bp"] diff --git a/backend/app/controllers/webhooks/stripe_identity.py b/backend/app/controllers/webhooks/stripe_identity.py index ddca4fe..da02b46 100644 --- a/backend/app/controllers/webhooks/stripe_identity.py +++ b/backend/app/controllers/webhooks/stripe_identity.py @@ -15,12 +15,14 @@ from app.core.errors import UnauthenticatedError, ValidationError from app.core.uow import SessionRole from app.extensions import DbRole +from app.integrations.redis.event_bus import RedisEventBus from app.integrations.stripe.kyc_adapter import StripeIdentitySignatureVerifier from app.models.ops.inbound_event import InboundEventSource from app.services.identity.account_approval_service import AccountApprovalService from app.services.identity.funding_uow import FundingUnitOfWork from app.services.identity.kyc_service import CustomerNotFoundError, KycService from app.services.intake.event_intake import EventIntakeService, IncomingEvent, IntakeResult +from app.services.ops.event_publisher import EventPublisher if TYPE_CHECKING: from app.services.intake.event_intake import IntakeUnitOfWork @@ -101,6 +103,10 @@ def stripe_identity_webhook() -> Any: return jsonify({"status": "ok"}), 200 +def _event_publisher() -> EventPublisher: + return EventPublisher(RedisEventBus(get_settings().redis_url)) + + def _apply_verdict(*, provider_session_id: str, stripe_status: str) -> None: settings = get_settings() with _funding_uow() as uow: @@ -113,6 +119,21 @@ def _apply_verdict(*, provider_session_id: str, stripe_status: str) -> None: uow.commit() return + account_approved = False if customer_id is not None: - AccountApprovalService(uow).approve_if_eligible(customer_id) + account_approved = AccountApprovalService(uow).approve_if_eligible(customer_id) uow.commit() + + # Real-time push (S12 §6) strictly after commit -- no I/O inside the transaction (S0 §5). + # The two gates publish independently -- never merged into one event. + if customer_id is not None: + publisher = _event_publisher() + publisher.identity_status_changed( + customer_id=str(customer_id), gate="kyc", summary="KYC verification status updated" + ) + if account_approved: + publisher.identity_status_changed( + customer_id=str(customer_id), + gate="account_approval", + summary="Account approved for funding and trading", + ) diff --git a/backend/app/integrations/fake/fake_event_bus.py b/backend/app/integrations/fake/fake_event_bus.py new file mode 100644 index 0000000..3fcc3ff --- /dev/null +++ b/backend/app/integrations/fake/fake_event_bus.py @@ -0,0 +1,40 @@ +"""`FakeEventBus` -- an in-memory `EventBusPort` for tests, mirroring `fake_broker.py`'s style: it +records every published message and lets a test push one and assert `subscribe` yields it. +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Generator, Mapping + + +class FakeEventBus: + """Implements `EventBusPort` (structural -- no inheritance required).""" + + def __init__(self) -> None: + self.published: list[tuple[str, dict[str, str]]] = [] + """Every `(channel, message)` pair passed to `publish`, in call order.""" + self._pending: dict[str, list[dict[str, str]]] = defaultdict(list) + + def publish(self, channel: str, message: Mapping[str, str]) -> None: + payload = dict(message) + self.published.append((channel, payload)) + self._pending[channel].append(payload) + + def subscribe(self, *channels: str) -> Generator[Mapping[str, str], None, None]: + """Pull-based: yields a queued message for one of `channels` as soon as `publish` has put + one there, else a heartbeat sentinel -- never sleeps, so a test drives it with `next()`.""" + while True: + for channel in channels: + queue = self._pending[channel] + if queue: + yield queue.pop(0) + break + else: + yield {"event_type": "heartbeat"} + + +__all__ = ["FakeEventBus"] diff --git a/backend/app/integrations/ports.py b/backend/app/integrations/ports.py index 5802bd0..f26ec3e 100644 --- a/backend/app/integrations/ports.py +++ b/backend/app/integrations/ports.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: import uuid + from collections.abc import Iterator, Mapping from datetime import date, datetime from app.core.money import Money, Price, Units @@ -246,3 +247,19 @@ def charge( """Raises `PaymentDeclinedError` on decline. `idempotency_key` must be stable across retries (S10 §5: `fee_charge.id`) so a retried outbox call never double-charges.""" ... + + +# --- EventBusPort (S12 §6, NFR-18 — Redis Pub/Sub real-time push) ----------------------------- + + +class EventBusPort(Protocol): + """A pointer-only push channel (S12 §6): a message here is never the sole record of a fact -- + the client always re-fetches. A dropped/never-open subscriber is not data loss.""" + + def publish(self, channel: str, message: Mapping[str, str]) -> None: ... + + def subscribe(self, *channels: str) -> Iterator[Mapping[str, str]]: + """Blocks, yielding each message as it arrives; yields a heartbeat sentinel periodically + with nothing else received, so a caller streaming this over HTTP can keep the connection + alive without holding a worker hostage forever.""" + ... diff --git a/backend/app/integrations/redis/__init__.py b/backend/app/integrations/redis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/integrations/redis/event_bus.py b/backend/app/integrations/redis/event_bus.py new file mode 100644 index 0000000..9e18774 --- /dev/null +++ b/backend/app/integrations/redis/event_bus.py @@ -0,0 +1,55 @@ +"""`RedisEventBus` (S12 §6, NFR-18) -- the only module that talks to Redis Pub/Sub for real-time +push. `app/services/` depends on `EventBusPort`, never this class directly (S0 §3); +`.importlinter`'s `services-use-ports-only` contract forbids `app.services` from importing +`app.integrations.redis`. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import redis + +if TYPE_CHECKING: + from collections.abc import Generator, Mapping + +_HEARTBEAT_MESSAGE: dict[str, str] = {"event_type": "heartbeat"} +_DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 20.0 + + +class RedisEventBus: + """Implements `EventBusPort` (structural -- no inheritance required).""" + + def __init__( + self, + redis_url: str, + *, + heartbeat_interval: float = _DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + ) -> None: + self._client = redis.Redis.from_url(redis_url, decode_responses=True) + self._heartbeat_interval = heartbeat_interval + + def publish(self, channel: str, message: Mapping[str, str]) -> None: + self._client.publish(channel, json.dumps(dict(message))) + + def subscribe(self, *channels: str) -> Generator[Mapping[str, str], None, None]: + """Opens a dedicated `pubsub()` connection, closed in `finally` however the caller stops + iterating -- via exhaustion, an explicit `.close()`, or garbage collection.""" + pubsub = self._client.pubsub() # type: ignore[no-untyped-call] # redis-py ships no stub + pubsub.subscribe(*channels) + try: + while True: + raw = pubsub.get_message( + ignore_subscribe_messages=True, timeout=self._heartbeat_interval + ) + if raw is None: + yield _HEARTBEAT_MESSAGE + continue + decoded: Mapping[str, str] = json.loads(raw["data"]) + yield decoded + finally: + pubsub.close() + + +__all__ = ["RedisEventBus"] diff --git a/backend/app/jobs/__init__.py b/backend/app/jobs/__init__.py index cedfa67..f6a80bc 100644 --- a/backend/app/jobs/__init__.py +++ b/backend/app/jobs/__init__.py @@ -66,10 +66,13 @@ def snapshot_cross_check_sweep_command(market_date: datetime) -> None: @click.option("--market-date", type=click.DateTime(formats=["%Y-%m-%d"]), required=True) def morning_reconciliation_command(market_date: datetime) -> None: """Run `MorningReconciliationJob` (S7). No real custodian feed yet -- uses the simulator.""" + from app.config import get_settings from app.core.db import DbRole from app.core.uow import SessionRole from app.integrations.fake.custodian_file_adapter import CustodianFileSimulatorAdapter + from app.integrations.redis.event_bus import RedisEventBus from app.jobs.morning_reconciliation import MorningReconciliationJob + from app.services.ops.event_publisher import EventPublisher from app.services.reconciliation.uow import ReconciliationUnitOfWork def _reconciliation_uow_factory() -> ReconciliationUnitOfWork: @@ -81,6 +84,7 @@ def _reconciliation_uow_factory() -> ReconciliationUnitOfWork: custodian_file_port=CustodianFileSimulatorAdapter( uow_factory=_reconciliation_uow_factory ), + event_publisher=EventPublisher(RedisEventBus(get_settings().redis_url)), ) outcome = job.run(market_date=market_date.date()) click.echo(outcome.value) @@ -181,12 +185,14 @@ def outbox_worker_command() -> None: from app.core.db import DbRole from app.core.logging import get_logger from app.core.uow import SessionRole + from app.integrations.redis.event_bus import RedisEventBus from app.models.ops import OpsUnitOfWork from app.models.ops.inbound_event import InboundEventSource from app.services.fees.fee_charge_outbox_handler import FeeChargeOutboxHandler from app.services.fees.uow import FeesUnitOfWork from app.services.intake.dispatch import InboundEventDispatcher from app.services.ledger.cash_policy_service import CashPolicyService + from app.services.ops.event_publisher import EventPublisher from app.services.ops.outbox_task_router import OutboxTaskRouter from app.services.orders.approval_hold_service import ApprovalHoldService from app.services.orders.holds_provider import OrderHoldsProvider @@ -207,11 +213,15 @@ def _orders_uow_factory() -> OrdersUnitOfWork: def _fees_uow_factory() -> FeesUnitOfWork: return FeesUnitOfWork(customer_id=None, role=SessionRole.ADMIN, db_role=DbRole.WORKER) + event_publisher = EventPublisher(RedisEventBus(settings.redis_url)) + dispatcher = InboundEventDispatcher() dispatcher.register( InboundEventSource.ALPACA, AlpacaTradeUpdateHandler( - uow_factory=_orders_uow_factory, now=lambda: dt.now(UTC) + uow_factory=_orders_uow_factory, + now=lambda: dt.now(UTC), + event_publisher=event_publisher, ).handle, ) dispatcher.register(InboundEventSource.STRIPE, lambda payload: None) diff --git a/backend/app/jobs/morning_reconciliation.py b/backend/app/jobs/morning_reconciliation.py index f74a0bd..d378aa3 100644 --- a/backend/app/jobs/morning_reconciliation.py +++ b/backend/app/jobs/morning_reconciliation.py @@ -20,6 +20,7 @@ from app.integrations.ports import CustodianFilePort from app.jobs.base import JobUnitOfWork + from app.services.ops.event_publisher import EventPublisher class _MorningReconciliationWorkUnitOfWork(ReconciliationUnitOfWork): @@ -40,6 +41,7 @@ def __init__( self, *, custodian_file_port: CustodianFilePort, + event_publisher: EventPublisher, uow_factory: Callable[[], JobUnitOfWork] = default_job_uow, work_uow_factory: Callable[ [], _MorningReconciliationWorkUnitOfWork @@ -48,6 +50,7 @@ def __init__( ) -> None: super().__init__(uow_factory=uow_factory, now=now) self._custodian_file_port = custodian_file_port + self._event_publisher = event_publisher self._work_uow_factory = work_uow_factory self._market_date: date | None = None @@ -64,8 +67,16 @@ def perform(self) -> None: market_clock = MarketClock(CachedTradingCalendar(uow.calendar_cache)) file_set = self._custodian_file_port.fetch_files(market_date=market_date) service = ReconciliationService(uow, market_clock=market_clock, now=self._now) - service.run_morning_reconciliation(market_date=market_date, file_set=file_set) + breaks = service.run_morning_reconciliation(market_date=market_date, file_set=file_set) uow.commit() + # Real-time push (S12 §6) strictly after commit, once per run -- never inside + # `ReconciliationService`'s own per-break loop (do not modify reconciliation_service.py). + for break_row in breaks: + self._event_publisher.break_opened( + break_id=str(break_row.id), + summary=f"{break_row.break_type.value} reconciliation break opened", + ) + __all__ = ["MorningReconciliationJob"] diff --git a/backend/app/services/ops/event_publisher.py b/backend/app/services/ops/event_publisher.py new file mode 100644 index 0000000..9eace8b --- /dev/null +++ b/backend/app/services/ops/event_publisher.py @@ -0,0 +1,76 @@ +"""`EventPublisher` (S12 §6, NFR-18) -- the only place a Pub/Sub channel string or the pushed +message shape is constructed. Callers publish through this, never `EventBusPort.publish` directly, +and always strictly after the triggering `uow.commit()` (S0 §5: no I/O inside a transaction) -- +a dropped/never-open subscriber is not data loss, since nothing published here is ever the sole +record of a fact; the next plain `GET` still returns truth. Consistently with that, a publish +failure here is always best-effort: it is logged and swallowed, never propagated to the caller -- +whose own commit has already succeeded and must not be undone (e.g. turned into a 500 that makes +Stripe retry an already-applied webhook) by a transient push-notification hiccup. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from app.core.logging import get_logger + +if TYPE_CHECKING: + from app.integrations.ports import EventBusPort + +log = get_logger(__name__) + +ADVISER_CHANNEL = "adviser:events" + + +def customer_channel(customer_id: str) -> str: + return f"customer:{customer_id}:events" + + +class EventPublisher: + def __init__(self, bus: EventBusPort) -> None: + self._bus = bus + + def order_updated(self, *, customer_id: str, order_id: str, summary: str) -> None: + self._publish( + customer_channel(customer_id), + {"event_type": "order_updated", "entity_id": order_id, "summary": summary}, + ) + + def identity_status_changed( + self, *, customer_id: str, gate: Literal["kyc", "account_approval"], summary: str + ) -> None: + """The two gates publish independently -- never merged into one event -- so `event_type` + itself encodes which gate changed.""" + self._publish( + customer_channel(customer_id), + { + "event_type": f"{gate}_status_changed", + "entity_id": customer_id, + "summary": summary, + }, + ) + + def break_opened(self, *, break_id: str, summary: str) -> None: + """Always the adviser channel, never a customer one -- a reconciliation break has no + single-customer audience (S7 §5.2).""" + self._publish( + ADVISER_CHANNEL, + {"event_type": "break_opened", "entity_id": break_id, "summary": summary}, + ) + + def _publish(self, channel: str, message: dict[str, str]) -> None: + """The one place that actually calls `EventBusPort.publish` -- every public method routes + through here so the best-effort/non-fatal contract described in the module docstring holds + for all three, not just whichever call site remembered to wrap it.""" + try: + self._bus.publish(channel, message) + except Exception: + log.warning( + "event_publish_failed", + channel=channel, + event_type=message.get("event_type"), + exc_info=True, + ) + + +__all__ = ["ADVISER_CHANNEL", "EventPublisher", "customer_channel"] diff --git a/backend/app/services/orders/trade_update_handler.py b/backend/app/services/orders/trade_update_handler.py index 07824b3..514a2ab 100644 --- a/backend/app/services/orders/trade_update_handler.py +++ b/backend/app/services/orders/trade_update_handler.py @@ -16,7 +16,7 @@ from app.core.clock import MarketClock from app.core.money import Price, Units from app.models.marketdata.trading_calendar import CachedTradingCalendar -from app.models.orders.order import OrderSide +from app.models.orders.order import TERMINAL_NON_FILLED_STATUSES, OrderSide, OrderStatus from app.models.orders.order_event import OrderEvent, OrderEventType, seq_from_timestamp from app.services.lots.lot_consumption_service import LotConsumptionService from app.services.orders.approval_hold_service import ApprovalHoldService @@ -26,6 +26,7 @@ from collections.abc import Callable from app.models.orders.order import Order + from app.services.ops.event_publisher import EventPublisher from app.services.orders.uow import OrdersUnitOfWork def _default_market_clock_factory(uow: OrdersUnitOfWork) -> MarketClock: @@ -41,6 +42,12 @@ def _default_market_clock_factory(uow: OrdersUnitOfWork) -> MarketClock: } """Mirrors `trade_updates_consumer._HANDLED_EVENTS`; duplicated to respect the layering rule.""" +_PUSH_WORTHY_STATUSES = frozenset({OrderStatus.FILLED, OrderStatus.PARTIALLY_FILLED}) | ( + TERMINAL_NON_FILLED_STATUSES +) +"""Resulting order statuses that warrant a real-time push (S12 §6) -- a fill/partial fill or one +of FR-38's terminal non-filled outcomes. `accepted`/`submitted` transitions are not pushed.""" + class _AlpacaTradeUpdateOrder(BaseModel): model_config = ConfigDict(extra="ignore") @@ -74,10 +81,12 @@ def __init__( *, uow_factory: Callable[[], OrdersUnitOfWork], now: Callable[[], datetime], + event_publisher: EventPublisher, market_clock_factory: Callable[[OrdersUnitOfWork], MarketClock] | None = None, ) -> None: self._uow_factory = uow_factory self._now = now + self._event_publisher = event_publisher self._market_clock_factory = market_clock_factory or _default_market_clock_factory def handle(self, payload: dict[str, Any]) -> None: @@ -102,7 +111,7 @@ def handle(self, payload: dict[str, Any]) -> None: execution_id = message.execution_id if event_type is OrderEventType.FILL else None hold_service = ApprovalHoldService(uow) projection = OrderProjectionService(uow, hold_service=hold_service, now=self._now) - projection.apply_new_event( + state = projection.apply_new_event( order, OrderEvent( order_id=order.id, @@ -117,8 +126,18 @@ def handle(self, payload: dict[str, Any]) -> None: if event_type is OrderEventType.FILL: self._record_lot_fill(uow, order=order, execution_id=execution_id, message=message) + customer_id = order.customer_id + order_id = order.id uow.commit() + # Real-time push (S12 §6) strictly after commit -- no I/O inside the transaction (S0 §5). + if state.status in _PUSH_WORTHY_STATUSES: + self._event_publisher.order_updated( + customer_id=str(customer_id), + order_id=str(order_id), + summary=f"Order status changed to {state.status.value}", + ) + def _record_lot_fill( self, uow: OrdersUnitOfWork, diff --git a/backend/app/views/events.py b/backend/app/views/events.py new file mode 100644 index 0000000..3c4ef9a --- /dev/null +++ b/backend/app/views/events.py @@ -0,0 +1,18 @@ +"""SSE payload schema for `app/controllers/api/events.py` and `app/controllers/admin/events.py` +(S12 §6). Explicit allow-list only, mirroring `app/views/chat.py`'s precedent -- the pushed event +is a contract too, not just an internal detail, and it must never carry more than a pointer for +the client to re-fetch by (S12 §6: never trust this payload as authoritative data). +""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class EventMessage(BaseModel): + event_type: str + entity_id: str + summary: str + + +__all__ = ["EventMessage"] diff --git a/backend/tests/integration/test_alpaca_trade_update_handler.py b/backend/tests/integration/test_alpaca_trade_update_handler.py index b11dc10..c06f866 100644 --- a/backend/tests/integration/test_alpaca_trade_update_handler.py +++ b/backend/tests/integration/test_alpaca_trade_update_handler.py @@ -15,6 +15,7 @@ from app.core.db import DbRole from app.core.money import Money, Units from app.core.uow import SessionRole +from app.integrations.fake.fake_event_bus import FakeEventBus from app.models.ledger.account import Account, AccountRole from app.models.ledger.lot_consumption import LotConsumption from app.models.ledger.tax_lot import TaxLot @@ -23,6 +24,7 @@ from app.models.orders.approval_hold import ApprovalHold from app.models.orders.order import Order, OrderSide, OrderStatus, derive_client_order_id from app.models.orders.order_event import OrderEvent, OrderEventType +from app.services.ops.event_publisher import EventPublisher from app.services.orders.approval_hold_service import ApprovalHoldService from app.services.orders.order_projection_service import OrderProjectionService from app.services.orders.trade_update_handler import ( @@ -119,7 +121,10 @@ def _seed_submitted_order(quantity: str = "10") -> tuple[uuid.UUID, str, uuid.UU def _handler() -> AlpacaTradeUpdateHandler: return AlpacaTradeUpdateHandler( - uow_factory=_owner_uow, now=lambda: NOW, market_clock_factory=_market_clock_factory + uow_factory=_owner_uow, + now=lambda: NOW, + market_clock_factory=_market_clock_factory, + event_publisher=EventPublisher(FakeEventBus()), ) diff --git a/backend/tests/integration/test_order_cancellation.py b/backend/tests/integration/test_order_cancellation.py index 886eba8..1e82d7f 100644 --- a/backend/tests/integration/test_order_cancellation.py +++ b/backend/tests/integration/test_order_cancellation.py @@ -16,6 +16,7 @@ from app.core.money import Money, Units from app.core.uow import SessionRole from app.integrations.fake.fake_broker import FakeBrokerAdapter +from app.integrations.fake.fake_event_bus import FakeEventBus from app.models.ledger.account import Account, AccountRole from app.models.ledger.lot_consumption import LotConsumption from app.models.ledger.tax_lot import TaxLot @@ -29,6 +30,7 @@ from app.models.orders.order import Order, OrderSide, OrderStatus, derive_client_order_id from app.models.orders.order_event import OrderEvent, OrderEventType from app.services.ledger.cash_policy_service import CashPolicyService +from app.services.ops.event_publisher import EventPublisher from app.services.orders.approval_hold_service import ApprovalHoldService from app.services.orders.holds_provider import OrderHoldsProvider from app.services.orders.order_projection_service import OrderProjectionService @@ -79,7 +81,10 @@ def _market_clock_factory(_uow: OrdersUnitOfWork) -> MarketClock: def _handler() -> AlpacaTradeUpdateHandler: return AlpacaTradeUpdateHandler( - uow_factory=_owner_uow, now=lambda: NOW, market_clock_factory=_market_clock_factory + uow_factory=_owner_uow, + now=lambda: NOW, + market_clock_factory=_market_clock_factory, + event_publisher=EventPublisher(FakeEventBus()), ) diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index 196da1b..3cdcb7f 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -37,6 +37,34 @@ server { proxy_buffering off; } + # S12 §6 (NFR-18) real-time push: long-lived SSE streams (~30 minute max lifetime, S12 §6's + # backend cap). Exact-match locations take precedence over the /api/ prefix above, so these + # two get their own proxy_read_timeout without touching the default for every other /api/ + # request. + location = /api/v1/events/stream { + proxy_pass ${BACKEND_URL}; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 35m; + } + + location = /api/v1/admin/events/stream { + proxy_pass ${BACKEND_URL}; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 35m; + } + location /health { proxy_pass ${BACKEND_URL}; proxy_http_version 1.1; From 83a3611ff1f817ac556d87f876c93e0fa50d3175 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Wed, 16 Sep 2026 12:46:00 -0700 Subject: [PATCH 08/14] feat(frontend): app shell rewrite -- 5-destination nav, EventStream provider Task 7 of the fullstack-rebuild plan. New `eventStream.js` (pure infra, same category as apiClient.js) wraps a singleton EventSource against Task 6's stream, with `subscribe(eventTypes, listener)`/backoff reconnect on repeated immediate failure. `EventStreamContext.jsx` connects on authenticated session, disconnects on logout/unmount. New `useLiveRefetch(eventTypes, refetch)` hook is the sole integration surface Tasks 8-12 use to wire live updates -- built and tested here, not called anywhere yet. `App.jsx`: five destinations (Dashboard/Invest/Money/Account/Chat) replace the previous ten routes, with a `` redirect from every old path so existing links don't 404. `/onboarding` stays a sibling route outside `RequireOnboarded`, guarded only by role -- unchanged from today, traced explicitly to avoid the redirect-loop trap nesting it would cause. `AppLayout.jsx`/`.css`: five nav links replace the grouped-links nav; `useDismissablePanel`/`getFocusable`/`initialsFromEmail`/`AccountMenu`/ `NavDrawer` kept verbatim. Mounts `EventStreamProvider` and `ThemeProvider` inside `SessionProvider`; `ThemeToggle` added to the header's right-hand cluster. The one property that must survive exactly on whatever now plays `.tu-app-layout__main`'s role: `display:flex; flex-direction:column; flex:1; min-height:0` -- Chat's transcript depends on it to scroll internally instead of the whole page scrolling. New `AppLayout.test.jsx` renders the shell and asserts those four computed properties on the main content element, specifically so a future change here can't silently regress Chat without a test failing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p --- frontend/src/App.jsx | 76 ++++++-- frontend/src/contexts/EventStreamContext.jsx | 46 +++++ frontend/src/hooks/useLiveRefetch.js | 10 + frontend/src/pages/AppLayout.css | 5 +- frontend/src/pages/AppLayout.jsx | 182 +++++++++---------- frontend/src/pages/AppLayout.test.jsx | 53 ++++++ frontend/src/pages/PageLayout.css | 15 ++ frontend/src/services/eventStream.js | 124 +++++++++++++ 8 files changed, 402 insertions(+), 109 deletions(-) create mode 100644 frontend/src/contexts/EventStreamContext.jsx create mode 100644 frontend/src/hooks/useLiveRefetch.js create mode 100644 frontend/src/pages/AppLayout.test.jsx create mode 100644 frontend/src/services/eventStream.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 567e959..f7b56da 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ -import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom' import { ToastProvider } from './components/Toast' import { SessionProvider, useSession } from './contexts/SessionContext.jsx' +import { AccountPage } from './pages/AccountPage.jsx' import { AppLayout } from './pages/AppLayout.jsx' import { BreakDetailPage } from './pages/admin/BreakDetailPage.jsx' import { BreaksQueuePage } from './pages/admin/BreaksQueuePage.jsx' @@ -8,18 +9,14 @@ import { CustomerDetailPage } from './pages/admin/CustomerDetailPage.jsx' import { CustomerDirectoryPage } from './pages/admin/CustomerDirectoryPage.jsx' import { ChatPage } from './pages/ChatPage.jsx' import { DashboardPage } from './pages/DashboardPage.jsx' -import { FeesPage } from './pages/FeesPage.jsx' -import { FundingPage } from './pages/FundingPage.jsx' +import { InvestPage } from './pages/InvestPage.jsx' import { LoginPage } from './pages/LoginPage.jsx' -import { LotsPage } from './pages/LotsPage.jsx' +import { MoneyPage } from './pages/MoneyPage.jsx' import { OnboardingPage } from './pages/OnboardingPage.jsx' import { OrderDetailPage } from './pages/OrderDetailPage.jsx' import { OrderNewPage } from './pages/OrderNewPage.jsx' -import { OrdersPage } from './pages/OrdersPage.jsx' -import { PortfolioPage } from './pages/PortfolioPage.jsx' import { RegisterPage } from './pages/RegisterPage.jsx' import { StatementDetailPage } from './pages/StatementDetailPage.jsx' -import { StatementsPage } from './pages/StatementsPage.jsx' import { defaultRouteForPrincipal } from './routes/defaultRoute.js' import { RequireAuth, RequireOnboarded, RequireRole } from './routes/guards.jsx' @@ -29,7 +26,32 @@ function defaultRouteFor(status, principal) { return status === 'authenticated' ? defaultRouteForPrincipal(principal) : '/login' } +/** Preserves the id when redirecting the old `/orders/:orderId` bookmark to its new home -- + * a bare `` would drop the id and strand the visitor on the list. */ +function OrderDetailRedirect() { + const { orderId } = useParams() + return +} + +/** Same trick as `OrderDetailRedirect`, plus the search string: a restated period's "View + * original as-published statement" link carries a `?publish_watermark=` query param that a bare + * `` would otherwise drop, stranding that old bookmark on the current (not original) + * figures. */ +function StatementDetailRedirect() { + const { periodStart } = useParams() + const location = useLocation() + return +} + // Every route in structure.md §2 is real; some are backed by a mock adapter, not a live endpoint. +// +// The 10 former customer routes collapse into 5 nav destinations (structure.md §2, Task 7). +// /invest is InvestPage (Task 9) -- PortfolioPage/OrdersPage/LotsPage folded into one, with +// /invest/orders/new and /invest/orders/:orderId as real sub-routes (restyled, not rebuilt). +// /money is MoneyPage (Task 10) -- the old FundingPage and (previously unrouted) FeesPage folded +// into one destination. /account is AccountPage (Task 11) -- profile (net-new), both identity +// gates, bank link, and statements, with /account/statements/:periodStart as its own real +// sub-route (StatementDetailPage, restyled not rebuilt). function AppShell() { const { status, principal } = useSession() @@ -41,6 +63,28 @@ function AppShell() { } /> } /> + + {/* Old paths -> new destination, so existing bookmarks/links don't 404. Each target route + re-applies its own guards on the follow-up render (e.g. an anonymous visitor still ends + up at /login), exactly as hitting the old path directly did before. `/orders/new`, + `/orders/:orderId`, and `/statements/:periodStart` get their own redirect (not a blanket + `/orders/*`/`/statements/*`) so the id (and, for statements, the `publish_watermark` + query param) survives the trip -- a bare `/invest`/`/account` would otherwise strand an + old bookmark on the list with no way back to the specific thing it named. `/lots` now + redirects to `/invest` (Task 9 folded `LotsPage` into `InvestPage`), not `/money`. + `/statements` (bare, no period) redirects to `/account`, not `/money` -- statements moved + there in Task 11's IA (Money = funding+fees only; Account = profile+identity+bank- + link+statements). */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + @@ -48,6 +92,9 @@ function AppShell() { } > + {/* /onboarding is a sibling OUTSIDE RequireOnboarded, guarded only by role -- nesting it + inside RequireOnboarded would be an infinite redirect loop (the guard fails closed, + redirects here, and immediately fails closed again). */} } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> :events`) or the adviser one (`adviser:events`, Task 12), never both. + * Connects once `useSession()` reports `authenticated` and disconnects on logout or unmount, so a + * signed-out tab never holds an open stream. + */ +export function EventStreamProvider({ children }) { + const { status, principal } = useSession() + const path = principal && STAFF_ROLES.includes(principal.role) ? ADMIN_EVENT_STREAM_PATH : CUSTOMER_EVENT_STREAM_PATH + const stream = useMemo(() => createEventStream(path), [path]) + const [connectionStatus, setConnectionStatus] = useState(stream.getStatus()) + + useEffect(() => { + const unsubscribeStatus = stream.subscribeStatus(setConnectionStatus) + if (status === 'authenticated') { + stream.connect() + } + return () => { + unsubscribeStatus() + stream.disconnect() + } + }, [status, stream]) + + const value = useMemo( + () => ({ subscribe: stream.subscribe, connectionStatus }), + [stream, connectionStatus], + ) + + return {children} +} + +export function useEventStream() { + const context = useContext(EventStreamContext) + if (context === undefined) { + throw new Error('useEventStream must be used within an EventStreamProvider') + } + return context +} diff --git a/frontend/src/hooks/useLiveRefetch.js b/frontend/src/hooks/useLiveRefetch.js new file mode 100644 index 0000000..e83077c --- /dev/null +++ b/frontend/src/hooks/useLiveRefetch.js @@ -0,0 +1,10 @@ +import { useEffect } from 'react' +import { useEventStream } from '../contexts/EventStreamContext.jsx' + +/** Re-runs `refetch` whenever a matching server-pushed event arrives (S12 §6). A subscribe/ + * unsubscribe primitive, not a data-fetch hook itself -- Tasks 8-12's own hooks call this + * alongside their normal fetch/pagination logic. */ +export function useLiveRefetch(eventTypes, refetch) { + const { subscribe } = useEventStream() + useEffect(() => subscribe(eventTypes, refetch), [subscribe, eventTypes, refetch]) +} diff --git a/frontend/src/pages/AppLayout.css b/frontend/src/pages/AppLayout.css index c2903e1..88bded8 100644 --- a/frontend/src/pages/AppLayout.css +++ b/frontend/src/pages/AppLayout.css @@ -368,7 +368,10 @@ that wants a bounded, internally-scrolling area (ChatPage) use `flex: 1` on itself instead, which propagates through pure flex-grow with no percentage-height gotcha. A single child with the default `align-items: stretch` behaves identically to normal block flow for every other - page, which already centers itself via max-width + margin auto. */ + page, which already centers itself via max-width + margin auto. + + These 4 properties are also set inline in AppLayout.jsx (`APP_LAYOUT_MAIN_STYLE`) as a second, + redundant guarantee -- see that constant's comment. Keep both in sync if either changes. */ display: flex; flex-direction: column; } diff --git a/frontend/src/pages/AppLayout.jsx b/frontend/src/pages/AppLayout.jsx index 546041b..96fa202 100644 --- a/frontend/src/pages/AppLayout.jsx +++ b/frontend/src/pages/AppLayout.jsx @@ -2,46 +2,32 @@ import { Fragment, useCallback, useEffect, useId, useRef, useState } from 'react import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom' import { Button } from '../components/Button' import { Icon } from '../components/Icon' +import { ThemeToggle } from '../components/ThemeToggle/ThemeToggle.jsx' +import { EventStreamProvider } from '../contexts/EventStreamContext.jsx' import { useSession } from '../contexts/SessionContext.jsx' +import { ThemeProvider } from '../contexts/ThemeContext.jsx' import { defaultRouteForPrincipal } from '../routes/defaultRoute.js' import './AppLayout.css' -// The full customer option set, always shown, clustered into 2 logical groups + one standalone -// pair (see AppLayout.css comment for why this is spacing/dividers, not a dropdown). -// -// These ten options are shown unconditionally, on purpose. An earlier revision gated them on -// `useIdentityStatus` so an unapproved customer saw only Onboarding, which broke the nav in two -// ways: while the status request was in flight the nav rendered EMPTY on every page load, and if -// that request ever failed or was rate-limited (it is capped at 30/min) an approved customer -// collapsed to a single Onboarding link. A guard that hides navigation on a slow or failed -// request is worse than one dead end: `RequireOnboarded` (routes/guards.jsx) already bounces an -// unapproved customer back to /onboarding, so the routing is enforced there regardless. +// The 5 customer destinations (structure.md §2, Task 7), always shown, on purpose. An earlier +// revision gated them on `useIdentityStatus` so an unapproved customer saw only Onboarding, which +// broke the nav in two ways: while the status request was in flight the nav rendered EMPTY on +// every page load, and if that request ever failed or was rate-limited (it is capped at 30/min) an +// approved customer collapsed to a single Onboarding link. A guard that hides navigation on a slow +// or failed request is worse than one dead end: `RequireOnboarded` (routes/guards.jsx) already +// bounces an unapproved customer back to /onboarding from any of these links, so the routing is +// enforced there regardless. No separate "Onboarding" link: /account now bundles that content, and +// the guard above routes there automatically for anyone not yet approved. const CUSTOMER_NAV_GROUPS = [ { - key: 'account', - label: 'Account', - links: [ - { to: '/dashboard', label: 'Dashboard' }, - { to: '/portfolio', label: 'Portfolio' }, - { to: '/orders', label: 'Orders' }, - ], - }, - { - key: 'money', - label: 'Money', - links: [ - { to: '/funding', label: 'Funding' }, - { to: '/lots', label: 'Tax lots' }, - { to: '/statements', label: 'Statements' }, - { to: '/fees', label: 'Fees' }, - ], - }, - { - key: 'ask', + key: 'primary', label: null, links: [ + { to: '/dashboard', label: 'Dashboard' }, + { to: '/invest', label: 'Invest' }, + { to: '/money', label: 'Money' }, + { to: '/account', label: 'Account' }, { to: '/chat', label: 'Ask Trueup' }, - { to: '/onboarding', label: 'Onboarding' }, ], }, ] @@ -61,6 +47,14 @@ function navLinkClassName({ isActive }) { return isActive ? 'tu-app-layout__link tu-app-layout__link--active' : 'tu-app-layout__link' } +// Belt-and-suspenders for the Chat layout contract (AppLayout.css's `.tu-app-layout__main` +// comment has the full story): Chat opts out of the page-level scroll and instead sizes itself via +// `flex: 1` up through this exact chain, so its transcript scrolls internally instead of the whole +// page scrolling. Setting these four properties inline as well as in the stylesheet means a future +// CSS-only edit to this file can't silently drop them -- and it's what makes them assertable in a +// test without a full CSS engine. +const APP_LAYOUT_MAIN_STYLE = { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } + /** * Which nav options this principal gets. Purely a function of role -- no network call, so the nav * renders complete on first paint and cannot be emptied or truncated by a slow, failed, or @@ -272,74 +266,78 @@ export function AppLayout() { }, [logout, navigate]) return ( -
-
-
- - Trueup - - - {hasNavItems && ( - - )} + Trueup + -
{hasNavItems && ( - + {groups.map((group, index) => ( + + {index > 0 && + ))} + )} - + +
+ {hasNavItems && ( + + )} + + +
-
-
+ - setDrawerOpen(false)} - triggerRef={menuTriggerRef} - /> + setDrawerOpen(false)} + triggerRef={menuTriggerRef} + /> -
- -
-
+
+ + + +
+
+
) } diff --git a/frontend/src/pages/AppLayout.test.jsx b/frontend/src/pages/AppLayout.test.jsx new file mode 100644 index 0000000..9fe31b6 --- /dev/null +++ b/frontend/src/pages/AppLayout.test.jsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { SessionProvider } from '../contexts/SessionContext.jsx' +import { AppLayout } from './AppLayout.jsx' + +// SessionProvider's mount-time refresh() must resolve/reject without a real network call. Reject +// (anonymous) is deliberate: it keeps EventStreamProvider's effect from ever calling connect(), +// which would instantiate `EventSource` -- not implemented in jsdom -- for a test that has nothing +// to do with the event stream. +vi.mock('../services/apiClient.js', () => ({ + apiClient: { + get: vi.fn().mockRejectedValue(new Error('no session')), + post: vi.fn(), + setCsrfToken: vi.fn(), + getCsrfToken: vi.fn(), + clearCsrfToken: vi.fn(), + }, + ApiError: class ApiError extends Error {}, +})) + +function renderShell() { + return render( + + + + }> + destination content} /> + + + + , + ) +} + +// This is the single highest-value test in the app shell rewrite: Chat (out of scope for any +// behavior change) sizes its internally-scrolling transcript by chaining `flex: 1; min-height: 0` +// up through `.tu-chat-page` -> `.tu-app-layout__main`. If a future edit to AppLayout drops any of +// these 4 properties from the element Outlet renders into, Chat silently starts scrolling the +// whole page instead of just its transcript -- this test is the only thing that would catch that. +describe('AppLayout main content wrapper', () => { + it('carries the flex column + bounded-height contract Chat depends on to scroll internally', async () => { + renderShell() + const main = await screen.findByRole('main') + + expect(main).toHaveStyle({ + display: 'flex', + flexDirection: 'column', + flex: '1', + minHeight: '0px', + }) + }) +}) diff --git a/frontend/src/pages/PageLayout.css b/frontend/src/pages/PageLayout.css index 8f1a41d..28b839b 100644 --- a/frontend/src/pages/PageLayout.css +++ b/frontend/src/pages/PageLayout.css @@ -32,6 +32,21 @@ color: var(--color-text); } +.tu-page__back-link { + display: inline-flex; + align-items: center; + gap: var(--space-1); + align-self: flex-start; + color: var(--color-text-secondary); + font-size: var(--font-size-caption); + text-decoration: none; +} + +.tu-page__back-link:hover { + color: var(--color-text); + text-decoration: underline; +} + .tu-page__section-title { font-size: var(--font-size-heading-sm); line-height: var(--line-height-heading-sm); diff --git a/frontend/src/services/eventStream.js b/frontend/src/services/eventStream.js new file mode 100644 index 0000000..b46e998 --- /dev/null +++ b/frontend/src/services/eventStream.js @@ -0,0 +1,124 @@ +// Thin transport wrapper around the browser's native EventSource, for the server-sent event push +// added in S12 §6 (`GET /api/v1/events/stream`, `GET /api/v1/admin/events/stream`). Same category +// as apiClient.js/mockClient.js -- pure infra, knows nothing about orders/KYC/breaks. EventSource +// is a legitimate exception to "always use apiClient": it's a native browser streaming primitive, +// not a fetch call (the same exception category chatApi.js's raw-fetch SSE reader already is). +// +// The server frames every event as a plain `data: \n\n` message with no `event:` line (see +// app/controllers/api/events.py), so the event type lives inside the JSON payload's `event_type` +// field rather than in EventSource's own named-event dispatch -- this listens on the generic +// `onmessage` and routes by that field itself. Heartbeats arrive as `: ping\n\n` comment lines, +// which EventSource never surfaces as a message at all. +const RECONNECT_BASE_DELAY_MS = 1000 +const RECONNECT_MAX_DELAY_MS = 30000 + +/** + * Creates one independent EventSource-backed stream against `path`. Not exported as a bare + * singleton -- `path` differs between the customer and admin variants (Task 12), so the shared + * logic lives here and each caller (EventStreamContext) owns exactly one instance for its path. + */ +export function createEventStream(path) { + let source = null + let reconnectTimer = null + let reconnectAttempts = 0 + let manuallyClosed = true + let status = 'idle' + const listenersByType = new Map() + const statusListeners = new Set() + + function setStatus(next) { + status = next + for (const listener of statusListeners) listener(status) + } + + function notify(eventType, payload) { + const listeners = listenersByType.get(eventType) + if (!listeners) return + for (const listener of listeners) listener(payload) + } + + function clearReconnectTimer() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + } + + function scheduleReconnect() { + if (manuallyClosed) return + const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttempts, RECONNECT_MAX_DELAY_MS) + reconnectAttempts += 1 + reconnectTimer = setTimeout(open, delay) + } + + function open() { + clearReconnectTimer() + setStatus('connecting') + source = new EventSource(path) + + source.onopen = () => { + reconnectAttempts = 0 + setStatus('open') + } + + source.onmessage = (event) => { + let payload + try { + payload = JSON.parse(event.data) + } catch { + return + } + if (!payload?.event_type) return + notify(payload.event_type, payload) + } + + source.onerror = () => { + // EventSource retries a dropped connection on its own by default. Only step in with our own + // backoff once the browser has actually given up (readyState CLOSED) -- e.g. repeated + // immediate failures -- rather than fighting its native retry for a transient blip. + if (source?.readyState === EventSource.CLOSED) { + setStatus('closed') + scheduleReconnect() + } + } + } + + return { + connect() { + manuallyClosed = false + if (source) return + open() + }, + disconnect() { + manuallyClosed = true + clearReconnectTimer() + reconnectAttempts = 0 + if (source) { + source.close() + source = null + } + setStatus('idle') + }, + subscribe(eventTypes, listener) { + for (const eventType of eventTypes) { + if (!listenersByType.has(eventType)) listenersByType.set(eventType, new Set()) + listenersByType.get(eventType).add(listener) + } + return () => { + for (const eventType of eventTypes) { + listenersByType.get(eventType)?.delete(listener) + } + } + }, + subscribeStatus(listener) { + statusListeners.add(listener) + return () => statusListeners.delete(listener) + }, + getStatus() { + return status + }, + } +} + +export const CUSTOMER_EVENT_STREAM_PATH = '/api/v1/events/stream' +export const ADMIN_EVENT_STREAM_PATH = '/api/v1/admin/events/stream' From d1738de2f85d15a06e7d4184f9e73a8128aff82f Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Wed, 16 Sep 2026 12:46:20 -0700 Subject: [PATCH 09/14] feat(frontend): Dashboard destination -- holdings, live chart, watchlist, ledger band Task 8 of the fullstack-rebuild plan. New hooks: `useHoldings` (a 404 with code "no_model_assigned" maps to status 'unassigned', rendering the existing `AssignmentPrompt` rather than `ErrorState`), `usePerformance`, `useCashSummary` (split out of `useFunding.js`, cash-summary half only). New `watchlist/` feature dir -- membership in localStorage (`trueup.watchlist.v1`), every read/write wrapped in try/catch so private-mode/storage-disabled renders empty state rather than throwing; prices from the real `GET /api/v1/securities`, no simulated generator. `securities/` feature dir replaces `portfolio/hooks/useSecurities.js` (deleted), now calling `GET /api/v1/securities` directly instead of deriving the universe from portfolio target weights. New `ValuationChart` (hand-rolled SVG line chart, no charting library -- pointer/keyboard scrub with an aria-live readout, dashed rendering for the provisional trailing segment, EmptyState under 2 points, reduced-motion respected) and `DriftMeter` (fill to current weight, tick at target, signed drift text, distinct treatment when flagged, no meter for the no-target CASH row). `HoldingsPanel` and `LedgerBand` (expandable bitemporal effective-vs-recorded rows, Task 5's "Load more") compose the page. `useHoldings`/`useCashSummary`/`useBalance` each gain one line -- `useLiveRefetch(['order_updated'], refetch)`. Backend: adds `previous_close` to the security-catalogue response (`views/securities.py`, `security_catalog_service.py`, `controllers/api/ securities.py`, `models/marketdata/daily_close.py`) for the watchlist's day-over-day change display. Fixes `tests/api/test_securities.py`'s two happy-path assertions, written in Task 2 before this field existed, to include it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p --- backend/app/controllers/api/securities.py | 29 +-- backend/app/models/marketdata/daily_close.py | 41 +++- .../marketdata/security_catalog_service.py | 13 +- backend/app/views/securities.py | 1 + backend/tests/api/test_securities.py | 2 + .../src/components/DriftMeter/DriftMeter.css | 64 ++++++ .../src/components/DriftMeter/DriftMeter.jsx | 48 +++++ frontend/src/components/DriftMeter/index.js | 1 + .../ValuationChart/ValuationChart.css | 70 +++++++ .../ValuationChart/ValuationChart.jsx | 183 +++++++++++++++++ .../src/components/ValuationChart/index.js | 1 + .../features/funding/hooks/useCashSummary.js | 52 +++++ .../features/portfolio/api/portfoliosApi.js | 3 + .../portfolio/components/AssignmentPrompt.jsx | 4 +- .../portfolio/components/HoldingsPanel.css | 5 + .../portfolio/components/HoldingsPanel.jsx | 118 +++++++++++ .../features/portfolio/hooks/useHoldings.js | 52 +++++ .../portfolio/hooks/usePerformance.js | 49 +++++ .../features/portfolio/hooks/useSecurities.js | 36 ---- .../features/securities/api/securitiesApi.js | 6 + .../securities/hooks/useSecurities.js | 47 +++++ .../valuation/components/LedgerBand.css | 34 ++++ .../valuation/components/LedgerBand.jsx | 124 ++++++++++++ .../features/valuation/hooks/useBalance.js | 3 + .../features/watchlist/api/watchlistApi.js | 30 +++ .../watchlist/components/WatchlistPanel.css | 75 +++++++ .../watchlist/components/WatchlistPanel.jsx | 153 ++++++++++++++ .../features/watchlist/hooks/useWatchlist.js | 31 +++ frontend/src/pages/DashboardPage.css | 97 ++++++++- frontend/src/pages/DashboardPage.jsx | 190 ++++++++++++------ 30 files changed, 1439 insertions(+), 123 deletions(-) create mode 100644 frontend/src/components/DriftMeter/DriftMeter.css create mode 100644 frontend/src/components/DriftMeter/DriftMeter.jsx create mode 100644 frontend/src/components/DriftMeter/index.js create mode 100644 frontend/src/components/ValuationChart/ValuationChart.css create mode 100644 frontend/src/components/ValuationChart/ValuationChart.jsx create mode 100644 frontend/src/components/ValuationChart/index.js create mode 100644 frontend/src/features/funding/hooks/useCashSummary.js create mode 100644 frontend/src/features/portfolio/components/HoldingsPanel.css create mode 100644 frontend/src/features/portfolio/components/HoldingsPanel.jsx create mode 100644 frontend/src/features/portfolio/hooks/useHoldings.js create mode 100644 frontend/src/features/portfolio/hooks/usePerformance.js delete mode 100644 frontend/src/features/portfolio/hooks/useSecurities.js create mode 100644 frontend/src/features/securities/api/securitiesApi.js create mode 100644 frontend/src/features/securities/hooks/useSecurities.js create mode 100644 frontend/src/features/valuation/components/LedgerBand.css create mode 100644 frontend/src/features/valuation/components/LedgerBand.jsx create mode 100644 frontend/src/features/watchlist/api/watchlistApi.js create mode 100644 frontend/src/features/watchlist/components/WatchlistPanel.css create mode 100644 frontend/src/features/watchlist/components/WatchlistPanel.jsx create mode 100644 frontend/src/features/watchlist/hooks/useWatchlist.js diff --git a/backend/app/controllers/api/securities.py b/backend/app/controllers/api/securities.py index f57cd6f..d82676d 100644 --- a/backend/app/controllers/api/securities.py +++ b/backend/app/controllers/api/securities.py @@ -6,7 +6,7 @@ from __future__ import annotations import uuid -from typing import Any +from typing import TYPE_CHECKING, Any from flask import Blueprint, jsonify, request from flask import session as flask_session @@ -24,6 +24,9 @@ SecurityResponse, ) +if TYPE_CHECKING: + from app.models.marketdata.daily_close import DailyClose + securities_bp = Blueprint("securities", __name__, url_prefix="/api/v1/securities") @@ -65,6 +68,16 @@ def list_securities() -> Any: with MarketDataUnitOfWork(customer_id=_uow_customer_id(role), role=role) as uow: rows, next_cursor = SecurityCatalogService(uow).list_active(limit=limit, after=after) + def _summary(daily_close: DailyClose | None) -> DailyCloseSummaryResponse | None: + if daily_close is None: + return None + return DailyCloseSummaryResponse( + price=daily_close.close_price, + market_date=daily_close.market_date, + source=daily_close.source.value, + status=daily_close.status.value, + ) + view = SecuritiesListResponse( securities=[ SecurityResponse( @@ -72,18 +85,10 @@ def list_securities() -> Any: symbol=security.symbol, name=security.name, asset_class=security.asset_class.value, - last_close=( - DailyCloseSummaryResponse( - price=daily_close.close_price, - market_date=daily_close.market_date, - source=daily_close.source.value, - status=daily_close.status.value, - ) - if daily_close is not None - else None - ), + last_close=_summary(last_close), + previous_close=_summary(previous_close), ) - for security, daily_close in rows + for security, last_close, previous_close in rows ], next_cursor=next_cursor, ) diff --git a/backend/app/models/marketdata/daily_close.py b/backend/app/models/marketdata/daily_close.py index a63c7d6..2d9b545 100644 --- a/backend/app/models/marketdata/daily_close.py +++ b/backend/app/models/marketdata/daily_close.py @@ -18,7 +18,7 @@ from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func, select from sqlalchemy import Enum as SQLAlchemyEnum from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, aliased, mapped_column from app.core.money import Price, PriceType from app.core.repository import BaseRepository @@ -126,3 +126,42 @@ def latest_for_securities( ) rows = self.session.execute(statement).scalars().all() return {row.security_id: row for row in rows} + + def previous_close_for_securities( + self, security_ids: Sequence[uuid.UUID] + ) -> dict[uuid.UUID, DailyClose]: + """Batched previous-trading-day close per security -- one query, not a loop. + + Two ranking passes, both batched in the same statement: + + 1. Collapse each `(security_id, market_date)` to its latest-`recorded_at` row via + `DISTINCT ON`, so a same-day correction counts as one trading day, not two. + 2. Rank those one-row-per-day results by `market_date DESC` within each security and + keep the row ranked 2nd -- the previous confirmed/stale trading day. A security with + fewer than two distinct trading days simply has no rank-2 row and is absent from the + result (`dict.get` at the call site yields `None`). + """ + if not security_ids: + return {} + per_day = ( + select(DailyClose) + .where(DailyClose.security_id.in_(security_ids)) + .distinct(DailyClose.security_id, DailyClose.market_date) + .order_by( + DailyClose.security_id, + DailyClose.market_date.desc(), + DailyClose.recorded_at.desc(), + ) + .subquery() + ) + per_day_entity = aliased(DailyClose, per_day) + ranked = select( + per_day_entity, + func.row_number() + .over(partition_by=per_day.c.security_id, order_by=per_day.c.market_date.desc()) + .label("day_rank"), + ).subquery() + ranked_entity = aliased(DailyClose, ranked) + statement = select(ranked_entity).where(ranked.c.day_rank == 2) + rows = self.session.execute(statement).scalars().all() + return {row.security_id: row for row in rows} diff --git a/backend/app/services/marketdata/security_catalog_service.py b/backend/app/services/marketdata/security_catalog_service.py index d161cb8..b44c70f 100644 --- a/backend/app/services/marketdata/security_catalog_service.py +++ b/backend/app/services/marketdata/security_catalog_service.py @@ -34,17 +34,20 @@ def __init__(self, uow: MarketDataUnitOfWork) -> None: def list_active( self, *, limit: int, after: str | None - ) -> tuple[list[tuple[Security, DailyClose | None]], str | None]: + ) -> tuple[list[tuple[Security, DailyClose | None, DailyClose | None]], str | None]: cursor = _decode_security_cursor(after) if after is not None else None rows = self._uow.securities.list_active(limit=limit, after=cursor) page = paginate( rows, limit=limit, cursor_key=lambda security: (security.symbol, str(security.id)) ) - closes = self._uow.daily_closes.latest_for_securities( - [security.id for security in page.items] - ) - paired = [(security, closes.get(security.id)) for security in page.items] + security_ids = [security.id for security in page.items] + last_closes = self._uow.daily_closes.latest_for_securities(security_ids) + previous_closes = self._uow.daily_closes.previous_close_for_securities(security_ids) + paired = [ + (security, last_closes.get(security.id), previous_closes.get(security.id)) + for security in page.items + ] return paired, page.next_cursor diff --git a/backend/app/views/securities.py b/backend/app/views/securities.py index fbe7bcd..e5d497c 100644 --- a/backend/app/views/securities.py +++ b/backend/app/views/securities.py @@ -29,6 +29,7 @@ class SecurityResponse(BaseModel): name: str asset_class: str last_close: DailyCloseSummaryResponse | None + previous_close: DailyCloseSummaryResponse | None class SecuritiesListResponse(BaseModel): diff --git a/backend/tests/api/test_securities.py b/backend/tests/api/test_securities.py index a1b1b64..0122c7a 100644 --- a/backend/tests/api/test_securities.py +++ b/backend/tests/api/test_securities.py @@ -165,6 +165,7 @@ def test_list_securities_happy_path_with_a_live_close( "source": "live", "status": "confirmed", }, + "previous_close": None, } ], "next_cursor": None, @@ -188,6 +189,7 @@ def test_list_securities_happy_path_with_no_close_is_null( "name": "ZZZ Co", "asset_class": "equity", "last_close": None, + "previous_close": None, } ], "next_cursor": None, diff --git a/frontend/src/components/DriftMeter/DriftMeter.css b/frontend/src/components/DriftMeter/DriftMeter.css new file mode 100644 index 0000000..91f77eb --- /dev/null +++ b/frontend/src/components/DriftMeter/DriftMeter.css @@ -0,0 +1,64 @@ +.tu-drift-meter { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 160px; +} + +.tu-drift-meter__value-only { + font-size: var(--font-size-data); + font-variant-numeric: tabular-nums; + color: var(--color-text); +} + +.tu-drift-meter__track { + position: relative; + flex: 1; + height: 8px; + border-radius: var(--radius-full); + background: var(--color-surface-sunken); + border: 1px solid var(--color-hairline); +} + +.tu-drift-meter--flagged .tu-drift-meter__track { + border: 1px dashed var(--color-warning); +} + +.tu-drift-meter__fill { + display: block; + height: 100%; + border-radius: var(--radius-full); + background: var(--color-accent); +} + +.tu-drift-meter--flagged .tu-drift-meter__fill { + background: var(--color-warning); +} + +.tu-drift-meter__tick { + position: absolute; + top: -2px; + bottom: -2px; + width: 2px; + background: var(--color-text); + transform: translateX(-1px); +} + +.tu-drift-meter__readout { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: var(--font-size-data); + font-variant-numeric: tabular-nums; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.tu-drift-meter--flagged .tu-drift-meter__readout { + color: var(--color-warning); + font-weight: var(--font-weight-data-strong); +} + +.tu-drift-meter__flag-icon { + color: var(--color-warning); +} diff --git a/frontend/src/components/DriftMeter/DriftMeter.jsx b/frontend/src/components/DriftMeter/DriftMeter.jsx new file mode 100644 index 0000000..b0f3993 --- /dev/null +++ b/frontend/src/components/DriftMeter/DriftMeter.jsx @@ -0,0 +1,48 @@ +import Decimal from 'decimal.js' +import { Icon } from '../Icon' +import './DriftMeter.css' + +function formatWeightPct(value) { + return `${new Decimal(value).times(100).toFixed(1)}%` +} + +function formatDriftPp(value) { + const decimal = new Decimal(value).times(100) + const sign = decimal.isNegative() ? '-' : '+' + return `${sign}${decimal.abs().toFixed(1)}pp` +} + +/** + * A horizontal track showing a holding's current weight against its target, with the signed drift + * printed as text (design-system.md's DriftMeter spec) — never color alone for `isFlagged`, which + * also gets a distinct icon+outline treatment, not just a color swap. + * + * `hasTarget={false}` renders the current weight as plain text with no meter — the implicit CASH + * line (`security_id: null`, `HoldingResponse`'s own convention) has nothing to compare against, + * so forcing a meter onto it would imply a target that doesn't exist. + */ +export function DriftMeter({ currentWeightPct, targetWeightPct, driftPct, isFlagged = false, hasTarget = true }) { + if (!hasTarget) { + return {formatWeightPct(currentWeightPct)} + } + + const currentPct = Math.min(100, Math.max(0, new Decimal(currentWeightPct).times(100).toNumber())) + const targetPct = Math.min(100, Math.max(0, new Decimal(targetWeightPct).times(100).toNumber())) + + return ( +
+
+ + +
+ + {isFlagged && } + {formatDriftPp(driftPct)} + +
+ ) +} diff --git a/frontend/src/components/DriftMeter/index.js b/frontend/src/components/DriftMeter/index.js new file mode 100644 index 0000000..f15cd2e --- /dev/null +++ b/frontend/src/components/DriftMeter/index.js @@ -0,0 +1 @@ +export { DriftMeter } from './DriftMeter.jsx' diff --git a/frontend/src/components/ValuationChart/ValuationChart.css b/frontend/src/components/ValuationChart/ValuationChart.css new file mode 100644 index 0000000..93dc01c --- /dev/null +++ b/frontend/src/components/ValuationChart/ValuationChart.css @@ -0,0 +1,70 @@ +.tu-valuation-chart { + position: relative; + width: 100%; + height: 160px; + outline: none; + cursor: crosshair; +} + +.tu-valuation-chart:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 4px; +} + +.tu-valuation-chart--empty { + cursor: default; + display: flex; + align-items: center; + justify-content: center; +} + +.tu-valuation-chart__svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.tu-valuation-chart__line { + fill: none; + stroke: var(--color-accent); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.tu-valuation-chart__line--provisional { + stroke-dasharray: 6 5; +} + +.tu-valuation-chart__guide { + stroke: var(--color-border-strong); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.tu-valuation-chart__dot { + fill: var(--color-accent); + stroke: var(--color-surface); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.tu-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + .tu-valuation-chart__line { + transition: none !important; + } +} diff --git a/frontend/src/components/ValuationChart/ValuationChart.jsx b/frontend/src/components/ValuationChart/ValuationChart.jsx new file mode 100644 index 0000000..bc44bf1 --- /dev/null +++ b/frontend/src/components/ValuationChart/ValuationChart.jsx @@ -0,0 +1,183 @@ +import Decimal from 'decimal.js' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { formatDate, formatMoney } from '../../utils/format.js' +import { EmptyState } from '../EmptyState' +import './ValuationChart.css' + +const VIEW_WIDTH = 600 +const VIEW_HEIGHT = 160 +const PADDING_Y = 12 +const DRAW_DURATION_MS = 700 + +function usePrefersReducedMotion() { + const [reduced, setReduced] = useState( + () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches, + ) + useEffect(() => { + const query = window.matchMedia('(prefers-reduced-motion: reduce)') + const handleChange = (event) => setReduced(event.matches) + query.addEventListener('change', handleChange) + return () => query.removeEventListener('change', handleChange) + }, []) + return reduced +} + +/** One {x, y} pair per point, in the chart's own `viewBox` units — `preserveAspectRatio="none"` on + * the `` lets it stretch to any container width without recomputing these. */ +function buildCoordinates(points) { + const values = points.map((point) => new Decimal(point.value)) + const min = Decimal.min(...values) + const max = Decimal.max(...values) + const span = max.minus(min) + const usableHeight = VIEW_HEIGHT - PADDING_Y * 2 + + return points.map((point, index) => { + const x = (index / (points.length - 1)) * VIEW_WIDTH + const ratio = span.isZero() ? 0.5 : new Decimal(point.value).minus(min).dividedBy(span).toNumber() + const y = PADDING_Y + (1 - ratio) * usableHeight + return { x, y } + }) +} + +function pathFor(coordinates) { + return coordinates.map((c, i) => `${i === 0 ? 'M' : 'L'}${c.x.toFixed(2)},${c.y.toFixed(2)}`).join(' ') +} + +/** + * Hand-rolled SVG line chart (no charting library, task-8-brief.md) for the Dashboard hero's live + * valuation series. Pointer-move and arrow-key scrub report the hovered/focused point via + * `onScrub(point | null)` — the caller (Dashboard hero) uses that to swap its balance readout + * between "live" and "scrubbed day". `isProvisional` dashes the final segment, since the live + * series' most recent point is today's still-forming close (S4/ADR 6) — there is no per-point + * provisional flag on the wire, only the series-level one. + */ +export function ValuationChart({ points, isProvisional = false, onScrub }) { + const containerRef = useRef(null) + const pathRef = useRef(null) + const [activeIndex, setActiveIndex] = useState(null) + const reducedMotion = usePrefersReducedMotion() + + const hasSeries = points.length >= 2 + const coordinates = useMemo(() => (hasSeries ? buildCoordinates(points) : []), [points, hasSeries]) + + const solidCoordinates = isProvisional && hasSeries ? coordinates.slice(0, -1) : coordinates + const provisionalCoordinates = isProvisional && hasSeries ? coordinates.slice(-2) : [] + const solidPath = solidCoordinates.length >= 2 ? pathFor(solidCoordinates) : pathFor(coordinates) + const provisionalPath = provisionalCoordinates.length === 2 ? pathFor(provisionalCoordinates) : '' + + // Single stroke-dashoffset "draw-in" on (re)mount — skipped entirely under reduced motion. + useLayoutEffect(() => { + const path = pathRef.current + if (!path) return undefined + if (reducedMotion) { + path.style.transition = 'none' + path.style.strokeDasharray = 'none' + path.style.strokeDashoffset = '0' + return undefined + } + const length = path.getTotalLength() + path.style.transition = 'none' + path.style.strokeDasharray = `${length}` + path.style.strokeDashoffset = `${length}` + const frame = requestAnimationFrame(() => { + path.style.transition = `stroke-dashoffset ${DRAW_DURATION_MS}ms var(--ease-standard, ease)` + path.style.strokeDashoffset = '0' + }) + return () => cancelAnimationFrame(frame) + }, [solidPath, reducedMotion]) + + useEffect(() => { + onScrub?.(activeIndex === null ? null : points[activeIndex]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- `points`/`onScrub` identity churn every fetch; re-run on index change only. + }, [activeIndex]) + + useEffect(() => { + // A fresh fetch (e.g. a range switch) can shrink the series — clamp rather than point at nothing. + setActiveIndex((prev) => (prev === null ? null : Math.min(prev, points.length - 1))) + }, [points.length]) + + if (!hasSeries) { + return ( +
+ +
+ ) + } + + const indexFromClientX = (clientX) => { + const rect = containerRef.current.getBoundingClientRect() + const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) + return Math.round(ratio * (points.length - 1)) + } + + const handlePointerMove = (event) => setActiveIndex(indexFromClientX(event.clientX)) + const handlePointerLeave = () => setActiveIndex(null) + const handleBlur = () => setActiveIndex(null) + + const handleKeyDown = (event) => { + const lastIndex = points.length - 1 + if (event.key === 'ArrowLeft') { + event.preventDefault() + setActiveIndex((prev) => Math.max(0, (prev ?? lastIndex) - 1)) + } else if (event.key === 'ArrowRight') { + event.preventDefault() + setActiveIndex((prev) => Math.min(lastIndex, (prev ?? lastIndex) + 1)) + } else if (event.key === 'Home') { + event.preventDefault() + setActiveIndex(0) + } else if (event.key === 'End') { + event.preventDefault() + setActiveIndex(lastIndex) + } + } + + const first = points[0] + const last = points[points.length - 1] + const ariaLabel = `Portfolio value chart, ${formatDate(first.as_of_date)} to ${formatDate(last.as_of_date)}: ${formatMoney(first.value)} to ${formatMoney(last.value)}` + const active = activeIndex === null ? null : points[activeIndex] + const activeCoordinate = activeIndex === null ? null : coordinates[activeIndex] + + return ( +
+ +

+ {active ? `${formatDate(active.as_of_date)}: ${formatMoney(active.value)}` : ''} +

+
+ ) +} diff --git a/frontend/src/components/ValuationChart/index.js b/frontend/src/components/ValuationChart/index.js new file mode 100644 index 0000000..b4cdc7e --- /dev/null +++ b/frontend/src/components/ValuationChart/index.js @@ -0,0 +1 @@ +export { ValuationChart } from './ValuationChart.jsx' diff --git a/frontend/src/features/funding/hooks/useCashSummary.js b/frontend/src/features/funding/hooks/useCashSummary.js new file mode 100644 index 0000000..b85ff84 --- /dev/null +++ b/frontend/src/features/funding/hooks/useCashSummary.js @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react' +import { useLiveRefetch } from '../../../hooks/useLiveRefetch.js' +import { fundingApi } from '../api/fundingApi.js' + +const IDLE = { status: 'idle', cashSummary: null, error: null, isRefreshing: false } + +/** + * `GET /funding/cash-summary` — the one place this fetch lives now. Originally split out of + * `useFunding.js` for the Dashboard's own read-only use (task-8-brief.md); Task 10 finished the + * split by moving `useFunding.js`'s post-submit `refresh()` cycle here too and deleting it, so the + * Money page's deposit/withdraw forms and the Dashboard share one cash-summary fetch instead of + * running a second, identical one. + * + * `refetch()` is the initial/retry path (`status` flips to `'loading'`, callers render their + * skeleton again). `refresh()` is the post-submit path (a successful deposit/withdrawal) — it + * re-fetches while keeping `status: 'loaded'` and flipping `isRefreshing` instead, so a successful + * submit updates the balance in place rather than blanking the page back to skeletons. + */ +export function useCashSummary() { + const [state, setState] = useState(IDLE) + + const refetch = useCallback(async () => { + setState((prev) => ({ ...prev, status: 'loading', error: null })) + try { + const cashSummary = await fundingApi.getCashSummary() + setState({ status: 'loaded', cashSummary, error: null, isRefreshing: false }) + } catch (error) { + setState({ status: 'error', cashSummary: null, error, isRefreshing: false }) + } + }, []) + + const refresh = useCallback(async () => { + setState((prev) => ({ ...prev, isRefreshing: true })) + try { + const cashSummary = await fundingApi.getCashSummary() + setState((prev) => ({ ...prev, status: 'loaded', cashSummary, error: null, isRefreshing: false })) + } catch { + // The deposit/withdrawal itself already succeeded — only the re-fetch failed. Keep the + // last-known-good balance on screen rather than blanking it over a transient error; the + // next refetch()/refresh() will catch up. + setState((prev) => ({ ...prev, isRefreshing: false })) + } + }, []) + + useLiveRefetch(['order_updated'], refetch) + + useEffect(() => { + refetch() + }, [refetch]) + + return { ...state, refetch, refresh } +} diff --git a/frontend/src/features/portfolio/api/portfoliosApi.js b/frontend/src/features/portfolio/api/portfoliosApi.js index 79b7b8b..2c685c4 100644 --- a/frontend/src/features/portfolio/api/portfoliosApi.js +++ b/frontend/src/features/portfolio/api/portfoliosApi.js @@ -17,4 +17,7 @@ export const portfoliosApi = { }) return data }, + getHoldings: () => apiClient.get('/portfolios/holdings'), + // `range` is the backend's own literal enum (1m|3m|6m|1y|all) — `app/controllers/api/portfolios.py`'s `_PerformanceQuery`. + getPerformance: (range) => apiClient.get(`/portfolios/performance?range=${range}`), } diff --git a/frontend/src/features/portfolio/components/AssignmentPrompt.jsx b/frontend/src/features/portfolio/components/AssignmentPrompt.jsx index 97b40a9..f1adca2 100644 --- a/frontend/src/features/portfolio/components/AssignmentPrompt.jsx +++ b/frontend/src/features/portfolio/components/AssignmentPrompt.jsx @@ -2,8 +2,8 @@ import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { ModelCard } from './ModelCard.jsx' import './AssignmentPrompt.css' -// design-system.md / structure.md §6 — `models` is fetched once at the page level (PortfolioPage) -// and passed down here to avoid a duplicate `GET /portfolios/models` call. +// design-system.md / structure.md §6 — `models` is fetched once at the page level (InvestPage's +// portfolio section) and passed down here to avoid a duplicate `GET /portfolios/models` call. export function AssignmentPrompt({ models, onSelect, assigning }) { return (
diff --git a/frontend/src/features/portfolio/components/HoldingsPanel.css b/frontend/src/features/portfolio/components/HoldingsPanel.css new file mode 100644 index 0000000..c9fc9a8 --- /dev/null +++ b/frontend/src/features/portfolio/components/HoldingsPanel.css @@ -0,0 +1,5 @@ +.tu-holdings-panel { + display: flex; + flex-direction: column; + gap: var(--space-3); +} diff --git a/frontend/src/features/portfolio/components/HoldingsPanel.jsx b/frontend/src/features/portfolio/components/HoldingsPanel.jsx new file mode 100644 index 0000000..08361d9 --- /dev/null +++ b/frontend/src/features/portfolio/components/HoldingsPanel.jsx @@ -0,0 +1,118 @@ +import { DriftMeter } from '../../../components/DriftMeter' +import { EmptyState } from '../../../components/EmptyState' +import { ErrorState } from '../../../components/ErrorState' +import { Skeleton } from '../../../components/Skeleton' +import { Table } from '../../../components/Table' +import { useToast } from '../../../components/Toast' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { formatMoney } from '../../../utils/format.js' +import { CompletenessBanner } from '../../valuation/components/CompletenessBanner.jsx' +import { useAssignment } from '../hooks/useAssignment.js' +import { useHoldings } from '../hooks/useHoldings.js' +import { useModels } from '../hooks/useModels.js' +import { AssignmentPrompt } from './AssignmentPrompt.jsx' +import './HoldingsPanel.css' + +/** + * Dashboard's real, live holdings — `useHoldings` (`GET /portfolios/holdings`). A customer with no + * assigned model yet gets `AssignmentPrompt` (the same model-picker `InvestPage`/Task 9 use), + * never `ErrorState` — `useHoldings`'s `unassigned` status exists specifically to route here. + */ +export function HoldingsPanel({ customerId }) { + const holdings = useHoldings() + const assignment = useAssignment(customerId) + const { status: modelsStatus, models, error: modelsError, refetch: refetchModels } = useModels() + const { showToast } = useToast() + + if (holdings.status === 'unassigned') { + const pickerLoading = + assignment.status === 'idle' || + assignment.status === 'loading' || + modelsStatus === 'idle' || + modelsStatus === 'loading' + + if (pickerLoading) { + return + } + + if (assignment.status === 'error' || modelsStatus === 'error') { + return ( + { + if (assignment.status === 'error') assignment.refetch() + if (modelsStatus === 'error') refetchModels() + }} + /> + ) + } + + const handleSelect = (modelId) => { + const chosen = models.find((model) => model.id === modelId) + return assignment + .assign(modelId) + .then(() => { + showToast({ message: chosen ? `You're now assigned to ${chosen.name}.` : 'Model assigned.', tone: 'success' }) + holdings.refetch() + }) + .catch(() => {}) + } + + return ( + + ) + } + + if (holdings.status === 'idle' || holdings.status === 'loading') { + return + } + + if (holdings.status === 'error') { + return + } + + if (holdings.holdings.length === 0) { + return ( + + ) + } + + return ( +
+ {holdings.completeness === 'partial' && } + + + Security + Market value + Weight / drift + + + {holdings.holdings.map((holding) => ( + + {holding.symbol ?? 'Cash'} + + {formatMoney(holding.market_value)} + + + + + + ))} + +
+
+ ) +} diff --git a/frontend/src/features/portfolio/hooks/useHoldings.js b/frontend/src/features/portfolio/hooks/useHoldings.js new file mode 100644 index 0000000..545b225 --- /dev/null +++ b/frontend/src/features/portfolio/hooks/useHoldings.js @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react' +import { useLiveRefetch } from '../../../hooks/useLiveRefetch.js' +import { portfoliosApi } from '../api/portfoliosApi.js' + +const IDLE = { + status: 'idle', + asOfDate: null, + completeness: null, + totalValue: null, + holdings: [], + error: null, +} +// status: 'idle' | 'loading' | 'loaded' | 'unassigned' | 'error' + +/** + * `GET /portfolios/holdings` (Dashboard, task-8-brief.md). A 404 with `code: "no_model_assigned"` + * is not a fetch failure — it means the customer hasn't chosen a model yet, so it maps to a + * distinct `unassigned` status rather than `error`; the owning component renders `AssignmentPrompt` + * for that case, never `ErrorState`. + */ +export function useHoldings() { + const [state, setState] = useState(IDLE) + + const refetch = useCallback(async () => { + setState((prev) => ({ ...prev, status: 'loading', error: null })) + try { + const data = await portfoliosApi.getHoldings() + setState({ + status: 'loaded', + asOfDate: data.as_of_date, + completeness: data.completeness, + totalValue: data.total_value, + holdings: data.holdings, + error: null, + }) + } catch (error) { + if (error.code === 'no_model_assigned') { + setState({ ...IDLE, status: 'unassigned' }) + return + } + setState({ ...IDLE, status: 'error', error }) + } + }, []) + + useLiveRefetch(['order_updated'], refetch) + + useEffect(() => { + refetch() + }, [refetch]) + + return { ...state, refetch } +} diff --git a/frontend/src/features/portfolio/hooks/usePerformance.js b/frontend/src/features/portfolio/hooks/usePerformance.js new file mode 100644 index 0000000..4705ba3 --- /dev/null +++ b/frontend/src/features/portfolio/hooks/usePerformance.js @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from 'react' +import { portfoliosApi } from '../api/portfoliosApi.js' + +const IDLE = { + status: 'idle', + range: null, + periodStart: null, + periodEnd: null, + cumulativeTwr: null, + isProvisional: false, + points: [], + error: null, +} + +/** + * `GET /portfolios/performance?range=` (ADR 26, live series) — `range` is one of the backend's own + * literal enum values (`1m|3m|6m|1y|all`, `app/controllers/api/portfolios.py`'s `_PerformanceQuery`). + * Refetches whenever `range` changes; the prior range's points stay in state through the `loading` + * transition (a spread of `prev`) so `ValuationChart` doesn't flash to empty on every range click — + * it only replaces on the next success or error. + */ +export function usePerformance(range) { + const [state, setState] = useState(IDLE) + + const refetch = useCallback(async () => { + setState((prev) => ({ ...prev, status: 'loading', error: null })) + try { + const data = await portfoliosApi.getPerformance(range) + setState({ + status: 'loaded', + range: data.range, + periodStart: data.period_start, + periodEnd: data.period_end, + cumulativeTwr: data.cumulative_twr, + isProvisional: data.is_provisional, + points: data.points, + error: null, + }) + } catch (error) { + setState({ ...IDLE, status: 'error', error }) + } + }, [range]) + + useEffect(() => { + refetch() + }, [refetch]) + + return { ...state, refetch } +} diff --git a/frontend/src/features/portfolio/hooks/useSecurities.js b/frontend/src/features/portfolio/hooks/useSecurities.js deleted file mode 100644 index f901196..0000000 --- a/frontend/src/features/portfolio/hooks/useSecurities.js +++ /dev/null @@ -1,36 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { portfoliosApi } from '../api/portfoliosApi.js' - -const IDLE = { status: 'idle', securities: [], error: null } - -// Orderable universe derived from GET /portfolios/models' target_weights; no quote endpoint exists. -export function useSecurities() { - const [state, setState] = useState(IDLE) - - const refetch = useCallback(async () => { - setState((prev) => ({ ...prev, status: 'loading', error: null })) - try { - const models = await portfoliosApi.getModels() - const bySecurityId = new Map() - for (const model of models) { - for (const weight of model.target_weights) { - if (!bySecurityId.has(weight.security_id)) { - bySecurityId.set(weight.security_id, { - security_id: weight.security_id, - symbol: weight.symbol, - }) - } - } - } - setState({ status: 'loaded', securities: [...bySecurityId.values()], error: null }) - } catch (error) { - setState({ status: 'error', securities: [], error }) - } - }, []) - - useEffect(() => { - refetch() - }, [refetch]) - - return { ...state, refetch } -} diff --git a/frontend/src/features/securities/api/securitiesApi.js b/frontend/src/features/securities/api/securitiesApi.js new file mode 100644 index 0000000..a38a673 --- /dev/null +++ b/frontend/src/features/securities/api/securitiesApi.js @@ -0,0 +1,6 @@ +import { apiClient } from '../../../services/apiClient.js' +import { paginationQuery } from '../../../utils/paginationQuery.js' + +export const securitiesApi = { + list: (pagination) => apiClient.get(`/securities${paginationQuery(pagination)}`), +} diff --git a/frontend/src/features/securities/hooks/useSecurities.js b/frontend/src/features/securities/hooks/useSecurities.js new file mode 100644 index 0000000..24530d5 --- /dev/null +++ b/frontend/src/features/securities/hooks/useSecurities.js @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from 'react' +import { securitiesApi } from '../api/securitiesApi.js' + +const IDLE = { status: 'idle', securities: [], nextCursor: null, error: null } +// status: 'idle' | 'loading' | 'loaded' | 'loading-more' | 'error' + +/** + * The tradable/watchable security catalogue — `GET /api/v1/securities` (Task 2). Moved from + * `features/portfolio/hooks/useSecurities.js`, which derived an orderable universe from + * `/portfolios/models`' target weights only (no price, no full catalogue); this hook calls the + * catalogue endpoint directly, so every security carries `last_close` for price display. + */ +export function useSecurities() { + const [state, setState] = useState(IDLE) + + const refetch = useCallback(async () => { + setState((prev) => ({ ...prev, status: 'loading', error: null })) + try { + const data = await securitiesApi.list() + setState({ status: 'loaded', securities: data.securities, nextCursor: data.next_cursor, error: null }) + } catch (error) { + setState({ status: 'error', securities: [], nextCursor: null, error }) + } + }, []) + + const loadMore = useCallback(async () => { + if (state.nextCursor === null || state.status === 'loading-more') return + setState((prev) => ({ ...prev, status: 'loading-more' })) + try { + const data = await securitiesApi.list({ after: state.nextCursor }) + setState((prev) => ({ + status: 'loaded', + securities: [...prev.securities, ...data.securities], + nextCursor: data.next_cursor, + error: null, + })) + } catch (error) { + setState((prev) => ({ ...prev, status: 'error', error })) + } + }, [state.nextCursor, state.status]) + + useEffect(() => { + refetch() + }, [refetch]) + + return { ...state, refetch, loadMore } +} diff --git a/frontend/src/features/valuation/components/LedgerBand.css b/frontend/src/features/valuation/components/LedgerBand.css new file mode 100644 index 0000000..04cf5d9 --- /dev/null +++ b/frontend/src/features/valuation/components/LedgerBand.css @@ -0,0 +1,34 @@ +.tu-ledger-band { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.tu-ledger-band__skeletons { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.tu-ledger-band__detail-row td { + background: var(--color-surface-sunken); + white-space: normal; +} + +.tu-ledger-band__detail { + display: flex; + flex-wrap: wrap; + gap: var(--space-6); + margin: 0; +} + +.tu-ledger-band__detail dt { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +.tu-ledger-band__detail dd { + margin: 0; + font-size: var(--font-size-body); + color: var(--color-text); +} diff --git a/frontend/src/features/valuation/components/LedgerBand.jsx b/frontend/src/features/valuation/components/LedgerBand.jsx new file mode 100644 index 0000000..15e7fea --- /dev/null +++ b/frontend/src/features/valuation/components/LedgerBand.jsx @@ -0,0 +1,124 @@ +import { Fragment, useState } from 'react' +import { Button } from '../../../components/Button' +import { EmptyState } from '../../../components/EmptyState' +import { ErrorState } from '../../../components/ErrorState' +import { Icon } from '../../../components/Icon' +import { Skeleton } from '../../../components/Skeleton' +import { Table } from '../../../components/Table' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { formatDate, formatDateTime, formatMoney, formatUnitsString } from '../../../utils/format.js' +import { useValuationHistory } from '../hooks/useValuationHistory.js' +import './LedgerBand.css' + +const ENTRY_LABEL = { + deposit: 'Deposit', + withdrawal: 'Withdrawal', + buy: 'Buy', + sell: 'Sell', + dividend: 'Dividend', + fee: 'Fee', +} + +/** + * Dashboard ledger band — `GET /valuation/history` (ADR 6, always the live variant; `useStatements` + * owns the as-published counterpart, never this one). Each row expands to show the bitemporal + * effective-vs-recorded distinction (ADR 1) plus the memo, rather than running those as permanent + * columns in an already-dense table. Self-contained (owns its own fetch) like `OrderList`/ + * `StatementList` — replaces the earlier, non-expandable `ValuationHistoryList` Task 5 built but + * never mounted. + */ +export function LedgerBand() { + const { status, entries, nextCursor, error, refetch, loadMore } = useValuationHistory() + const [expandedKey, setExpandedKey] = useState(null) + + if (status === 'idle' || status === 'loading') { + return ( +
+ + + +
+ ) + } + + if (status === 'error' && entries.length === 0) { + return + } + + if (entries.length === 0) { + return + } + + return ( +
+ + + + + {entries.map((entry, index) => { + const key = `${entry.effective_date}-${entry.recorded_at}-${index}` + const expanded = expandedKey === key + return ( + + setExpandedKey(expanded ? null : key)} aria-expanded={expanded}> + + + + {formatDate(entry.effective_date)} + {ENTRY_LABEL[entry.entry_type] ?? entry.entry_type} + + {entry.amount_money === null ? '—' : formatMoney(entry.amount_money)} + + + {entry.quantity_units === null ? '—' : formatUnitsString(entry.quantity_units)} + + + {expanded && ( + + + + )} + + ) + })} + +
+ +
+
+
Effective date
+
{formatDate(entry.effective_date)}
+
+
+
Recorded
+
{formatDateTime(entry.recorded_at)}
+
+
+
Memo
+
{entry.memo ?? '—'}
+
+
+
+ {status === 'error' ? ( + + ) : ( + nextCursor !== null && ( + + ) + )} +
+ ) +} diff --git a/frontend/src/features/valuation/hooks/useBalance.js b/frontend/src/features/valuation/hooks/useBalance.js index a9c27cf..246933a 100644 --- a/frontend/src/features/valuation/hooks/useBalance.js +++ b/frontend/src/features/valuation/hooks/useBalance.js @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useLiveRefetch } from '../../../hooks/useLiveRefetch.js' import { valuationApi } from '../api/valuationApi.js' const IDLE = { status: 'idle', totalValue: null, asOfDate: null, completeness: null, error: null } @@ -22,6 +23,8 @@ export function useBalance() { } }, []) + useLiveRefetch(['order_updated'], refetch) + useEffect(() => { refetch() }, [refetch]) diff --git a/frontend/src/features/watchlist/api/watchlistApi.js b/frontend/src/features/watchlist/api/watchlistApi.js new file mode 100644 index 0000000..2aa9190 --- /dev/null +++ b/frontend/src/features/watchlist/api/watchlistApi.js @@ -0,0 +1,30 @@ +const STORAGE_KEY = 'trueup.watchlist.v1' + +function parseIds(raw) { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter((id) => typeof id === 'string') +} + +/** + * Watchlist membership lives entirely in `localStorage` — there is no backend list. Every read and + * write is wrapped in try/catch so a private-mode or storage-disabled browser degrades to an empty + * watchlist instead of throwing (task-8-brief.md). + */ +export const watchlistApi = { + load() { + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + return raw ? parseIds(raw) : [] + } catch { + return [] + } + }, + save(securityIds) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(securityIds)) + } catch { + // Storage unavailable, full, or private-mode — membership stays in-memory for this session. + } + }, +} diff --git a/frontend/src/features/watchlist/components/WatchlistPanel.css b/frontend/src/features/watchlist/components/WatchlistPanel.css new file mode 100644 index 0000000..a24c4f9 --- /dev/null +++ b/frontend/src/features/watchlist/components/WatchlistPanel.css @@ -0,0 +1,75 @@ +.tu-watchlist-panel { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.tu-watchlist-panel__header { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.tu-watchlist-panel__header .tu-page__section-title { + margin: 0; +} + +.tu-watchlist-panel__add { + display: flex; + align-items: flex-end; + gap: var(--space-2); +} + +.tu-watchlist-panel__add .tu-field { + flex: 1; +} + +.tu-watchlist-panel__symbol { + display: block; + color: var(--color-text); + font-weight: var(--font-weight-body-medium); +} + +.tu-watchlist-panel__name { + display: block; + font-size: var(--font-size-caption); + color: var(--color-text-muted); +} + +.tu-watchlist-panel__as-of { + display: block; + font-size: var(--font-size-caption); + color: var(--color-text-muted); + font-variant-numeric: normal; +} + +.tu-watchlist-panel__change--gain { + color: var(--color-success); +} + +.tu-watchlist-panel__change--loss { + color: var(--color-error); +} + +.tu-watchlist-panel__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; +} + +.tu-watchlist-panel__remove:hover { + background: var(--color-surface-sunken); + color: var(--color-error); +} + +.tu-watchlist-panel__remove:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} diff --git a/frontend/src/features/watchlist/components/WatchlistPanel.jsx b/frontend/src/features/watchlist/components/WatchlistPanel.jsx new file mode 100644 index 0000000..e64ef82 --- /dev/null +++ b/frontend/src/features/watchlist/components/WatchlistPanel.jsx @@ -0,0 +1,153 @@ +import Decimal from 'decimal.js' +import { useState } from 'react' +import { Button } from '../../../components/Button' +import { EmptyState } from '../../../components/EmptyState' +import { ErrorState } from '../../../components/ErrorState' +import { Icon } from '../../../components/Icon' +import { Select } from '../../../components/Select' +import { SimulatedBadge } from '../../../components/SimulatedBadge' +import { Skeleton } from '../../../components/Skeleton' +import { Table } from '../../../components/Table' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { formatDate, formatMoney, formatPercent } from '../../../utils/format.js' +import { useSecurities } from '../../securities/hooks/useSecurities.js' +import { useWatchlist } from '../hooks/useWatchlist.js' +import './WatchlistPanel.css' + +/** `last_close.price` vs `previous_close.price` as a signed fraction, or `null` when either side + * is missing (a brand-new instrument with no prior close yet) or `previous_close.price` is zero — + * never divide by something that doesn't exist rather than render a change indicator. */ +function dayChangeFraction(security) { + const last = security.last_close + const previous = security.previous_close + if (!last || !previous) return null + const previousPrice = new Decimal(previous.price) + if (previousPrice.isZero()) return null + return new Decimal(last.price).minus(previousPrice).dividedBy(previousPrice) +} + +/** + * Dashboard watchlist — membership is local (`useWatchlist`, `localStorage`), prices and + * day-over-day change are real (`useSecurities` → `GET /api/v1/securities`, Task 2's + * `last_close`/`previous_close` fields). No sparkline — ruled out by the controller: a genuine + * multi-point series is a disproportionate cost on a paginated catalogue endpoint for what's a + * decorative flourish, versus the two-point `previous_close` addition that cheaply and honestly + * covers the one actually-informative sub-feature. + */ +export function WatchlistPanel() { + const { securityIds, add, remove } = useWatchlist() + const { status, securities, error, refetch } = useSecurities() + const [pendingSymbolId, setPendingSymbolId] = useState('') + + const watched = securities.filter((security) => securityIds.includes(security.security_id)) + const available = securities.filter((security) => !securityIds.includes(security.security_id)) + const hasSimulatedPricing = watched.some((security) => security.last_close?.source === 'simulated') + + const handleAdd = (event) => { + event.preventDefault() + if (!pendingSymbolId) return + add(pendingSymbolId) + setPendingSymbolId('') + } + + return ( +
+
+

Watchlist

+ {hasSimulatedPricing && } +
+ + {status === 'error' ? ( + + ) : ( + <> +
+ + +
+ + {status === 'idle' || status === 'loading' ? ( + + ) : watched.length === 0 ? ( + + ) : ( + + + Symbol + Last price + Change + + + {watched.map((security) => { + const change = dayChangeFraction(security) + return ( + + + {security.symbol} + {security.name} + + + {security.last_close ? ( + <> + {formatMoney(security.last_close.price)} + + {' '} + as of {formatDate(security.last_close.market_date)} + + + ) : ( + '—' + )} + + + {change === null ? ( + '—' + ) : ( + + {formatPercent(change.toString())} + + )} + + + + + + ) + })} + +
+ )} + + )} +
+ ) +} diff --git a/frontend/src/features/watchlist/hooks/useWatchlist.js b/frontend/src/features/watchlist/hooks/useWatchlist.js new file mode 100644 index 0000000..4629359 --- /dev/null +++ b/frontend/src/features/watchlist/hooks/useWatchlist.js @@ -0,0 +1,31 @@ +import { useCallback, useState } from 'react' +import { watchlistApi } from '../api/watchlistApi.js' + +/** + * Watchlist membership (an array of `security_id`), persisted to `localStorage`. Purely local, + * synchronous state — no network call, so there is no `status` fetch machine here. Pricing for the + * watched securities is a separate concern (`useSecurities`) that `WatchlistPanel` composes this + * hook with. + */ +export function useWatchlist() { + const [securityIds, setSecurityIds] = useState(watchlistApi.load) + + const add = useCallback((securityId) => { + setSecurityIds((prev) => { + if (prev.includes(securityId)) return prev + const next = [...prev, securityId] + watchlistApi.save(next) + return next + }) + }, []) + + const remove = useCallback((securityId) => { + setSecurityIds((prev) => { + const next = prev.filter((id) => id !== securityId) + watchlistApi.save(next) + return next + }) + }, []) + + return { securityIds, add, remove } +} diff --git a/frontend/src/pages/DashboardPage.css b/frontend/src/pages/DashboardPage.css index 96ded07..88a980b 100644 --- a/frontend/src/pages/DashboardPage.css +++ b/frontend/src/pages/DashboardPage.css @@ -1,11 +1,90 @@ -/* BalanceCard/ReturnCard are content-sized Cards with no width of their own -- now that - PageLayout.css's .tu-page actually stretches to the full page width, a plain flex row would - just leave them small and left-aligned with the rest of the row empty. auto-fit lets each stat - grow to share the row evenly (2 columns once there's room for both at >=280px, 1 below that), - matching the repeat(auto-fill/auto-fit, minmax(...)) idiom already used in - features/portfolio/components/ModelDetail.css and AssignmentPrompt.css. */ -.tu-dashboard-page__stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +.tu-hero-plate { + display: flex; + flex-direction: column; gap: var(--space-4); } + +.tu-hero-plate__figure { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.tu-hero-plate__label { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +.tu-hero-plate__value { + font-family: var(--font-serif); + font-size: var(--font-size-display-xl); + line-height: var(--line-height-display-xl); + font-weight: var(--font-weight-display-xl); + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +.tu-hero-plate__as-of { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--font-size-caption); + color: var(--color-text-muted); +} + +.tu-hero-plate__twr { + font-variant-numeric: tabular-nums; + font-weight: var(--font-weight-body-medium); +} + +.tu-hero-plate__twr--gain { + color: var(--color-success); +} + +.tu-hero-plate__twr--loss { + color: var(--color-error); +} + +.tu-hero-plate__range-toggle { + display: inline-flex; + gap: var(--space-1); + align-self: flex-start; + border: 1px solid var(--color-hairline); + border-radius: var(--radius-sm); + padding: var(--space-1); +} + +.tu-hero-plate__range-button { + border: none; + background: none; + border-radius: var(--radius-sm); + padding: var(--space-1) var(--space-3); + font-size: var(--font-size-caption); + font-weight: var(--font-weight-body-medium); + color: var(--color-text-secondary); + cursor: pointer; + min-height: 28px; +} + +.tu-hero-plate__range-button:hover { + color: var(--color-text); +} + +.tu-hero-plate__range-button--active { + background: var(--color-accent-subtle); + color: var(--color-accent-strong); +} + +.tu-hero-plate__range-button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* Holdings/Watchlist side by side (approved mockup) -- stacks to one column below `lg`, matching + design-system.md §5's "two-column dashboard stacks to one column" breakpoint rule. */ +.tu-dashboard-page__panels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: var(--space-8); + align-items: start; +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index fa3a0f1..a63e17b 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,79 +1,153 @@ +import Decimal from 'decimal.js' import { useState } from 'react' +import { Badge } from '../components/Badge' +import { Card } from '../components/Card' +import { ErrorState } from '../components/ErrorState' +import { Skeleton } from '../components/Skeleton' +import { ValuationChart } from '../components/ValuationChart' import { useSession } from '../contexts/SessionContext.jsx' -import { BalanceCard } from '../features/valuation/components/BalanceCard.jsx' +import { CashSummary } from '../features/funding/components/CashSummary.jsx' +import { useCashSummary } from '../features/funding/hooks/useCashSummary.js' +import { HoldingsPanel } from '../features/portfolio/components/HoldingsPanel.jsx' +import { usePerformance } from '../features/portfolio/hooks/usePerformance.js' import { CompletenessBanner } from '../features/valuation/components/CompletenessBanner.jsx' -import { ReturnCard } from '../features/valuation/components/ReturnCard.jsx' +import { LedgerBand } from '../features/valuation/components/LedgerBand.jsx' import { useBalance } from '../features/valuation/hooks/useBalance.js' -import { useReturns } from '../features/valuation/hooks/useReturns.js' -import { HoldingsTable } from '../features/portfolio/components/HoldingsTable.jsx' -import { useAssignment } from '../features/portfolio/hooks/useAssignment.js' -import { useModels } from '../features/portfolio/hooks/useModels.js' +import { WatchlistPanel } from '../features/watchlist/components/WatchlistPanel.jsx' +import { getErrorMessage } from '../utils/apiErrorMessage.js' +import { formatDate, formatMoney, formatPercent } from '../utils/format.js' import './DashboardPage.css' import './PageLayout.css' -function monthToDateRange() { - const now = new Date() - const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) - return { periodStart: monthStart.toISOString(), periodEnd: now.toISOString() } -} +const RANGES = [ + { value: '1m', label: '1M' }, + { value: '3m', label: '3M' }, + { value: '6m', label: '6M' }, + { value: '1y', label: '1Y' }, + { value: 'all', label: 'All' }, +] -export function DashboardPage() { - const { principal } = useSession() +/** + * The hero plate: a live-readout balance figure backed by `useBalance`, and — the approved + * mockup's "scrubbable" behavior — swapped for the scrubbed day's value while `ValuationChart` + * reports a non-null point. `usePerformance`'s own series backs the chart; `useBalance`'s figure is + * the resting/default state, not the chart's own last point, since the two endpoints can have + * different `as_of_date`s (balance is always "as of today", the chart's last point may lag by a + * beat during market hours). + */ +function HeroPlate() { const balance = useBalance() - // Computed once per mount, not per render — a stable dependency for `useReturns` below. - const [{ periodStart, periodEnd }] = useState(monthToDateRange) - const returns = useReturns(periodStart, periodEnd) - const assignment = useAssignment(principal.id) - const { status: modelsStatus, models, error: modelsError, refetch: refetchModels } = useModels() + const [range, setRange] = useState('1m') + const performance = usePerformance(range) + const [scrubPoint, setScrubPoint] = useState(null) - // Both the assignment and the model list must be loaded before deciding whether this customer - // has an assigned model — deciding early (an empty, still-loading `models` array) previously - // flashed the "no model assigned" empty state for an already-assigned customer on every load. - const targetAllocationStatus = - assignment.status === 'error' || modelsStatus === 'error' - ? 'error' - : assignment.status === 'loaded' && modelsStatus === 'loaded' - ? 'ready' - : 'loading' + const isScrubbing = scrubPoint !== null + const readoutValue = isScrubbing ? scrubPoint.value : balance.totalValue + const readoutDate = isScrubbing ? scrubPoint.as_of_date : balance.asOfDate + + const chartHasNoData = performance.status !== 'error' && performance.points.length === 0 + const chartIsLoading = (performance.status === 'idle' || performance.status === 'loading') && chartHasNoData + + return ( + + {!isScrubbing && balance.completeness === 'partial' && } - const assignedModel = - targetAllocationStatus === 'ready' && assignment.assignment - ? models.find((model) => model.id === assignment.assignment.model_portfolio_id) - : null +
+ {isScrubbing ? `Value on ${formatDate(readoutDate)}` : 'Account balance'} + {balance.status === 'loading' || balance.status === 'idle' ? ( + + ) : balance.status === 'error' ? ( + + ) : ( + <> + {formatMoney(readoutValue)} + {!isScrubbing && ( + + As of {formatDate(balance.asOfDate)} + {performance.status === 'loaded' && performance.points.length > 0 && ( + <> + {' · '} + + {formatPercent(performance.cumulativeTwr)} + {' '} + this range + {performance.isProvisional && Provisional} + + )} + + )} + + )} +
+ +
+ {RANGES.map((option) => ( + + ))} +
+ + {performance.status === 'error' && performance.points.length === 0 ? ( + + ) : chartIsLoading ? ( + + ) : ( + + )} +
+ ) +} - const retryTargetAllocation = () => { - if (assignment.status === 'error') assignment.refetch() - if (modelsStatus === 'error') refetchModels() - } +export function DashboardPage() { + const { principal } = useSession() + const cashSummary = useCashSummary() return (

Dashboard

- {balance.completeness === 'partial' && } -
- - + + + + + {cashSummary.status === 'idle' || cashSummary.status === 'loading' ? ( + + ) : cashSummary.status === 'error' ? ( + + ) : ( + + )} + + +
+
+

Holdings

+ +
+
+ +
+
-

Target allocation

- +

Activity

+
) From 34909230e60a3ef168155cd7b150d057380d143b Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Wed, 16 Sep 2026 12:46:36 -0700 Subject: [PATCH 10/14] feat(frontend): Invest destination -- portfolio + orders + lots + lot-picker Task 9 of the fullstack-rebuild plan. Folds `PortfolioPage.jsx` and `LotsPage.jsx` into a new `InvestPage.jsx`, reusing Task 8's `useHoldings`/`DriftMeter` rather than duplicating them. Deletes `OrdersPage.jsx` (18 lines of pure chrome) -- its header and "New order" button move directly into `InvestPage`. `OrderNewPage.jsx`/ `OrderDetailPage.jsx` are restyle-only, same hooks and logic as before. `useOrders`/`useOrder` each gain `useLiveRefetch(['order_updated'], refetch)`. `usePlaceOrder` gains a `lotIds` param threaded to `POST /orders`'s `lot_ids` field (Task 3's contract), meaningful only for sell orders. New `LotPicker`/`useLotPicker`: shown only when the order form's side is 'sell', sourced from lots with `quantity_remaining > 0` for the selected security. Shows each candidate's remaining quantity, acquisition date, and unrealized gain/loss (already on `GET /lots`, not re-derived); disables submission when the selection under-covers the requested quantity as a UX nicety -- Task 3's server-side check remains the actual authority. No selection means FIFO, the default, undo-able state. `OrderDetail.jsx` renders `designated_lot_ids` when present. `LotTable.jsx` gets a visible wash-sale-disallowed column (`wash_sale_disallowed`, already returned by the backend, previously ignored by the UI) -- always present as a column rather than a conditional badge, since the field is always populated (Money, "0.00" when nothing was disallowed). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p --- frontend/src/features/lots/api/lotsApi.js | 10 +- .../src/features/lots/components/LotTable.jsx | 216 ++++++++++-------- frontend/src/features/orders/api/ordersApi.js | 3 +- .../features/orders/components/LotPicker.css | 53 +++++ .../features/orders/components/LotPicker.jsx | 115 ++++++++++ .../orders/components/OrderDetail.css | 11 + .../orders/components/OrderDetail.jsx | 16 ++ .../features/orders/components/OrderForm.jsx | 33 ++- .../src/features/orders/hooks/useLotPicker.js | 72 ++++++ .../src/features/orders/hooks/useOrder.js | 3 + .../features/orders/hooks/usePlaceOrder.js | 12 +- .../src/features/orders/ordersErrorMessage.js | 6 + frontend/src/pages/InvestPage.css | 17 ++ frontend/src/pages/InvestPage.jsx | 186 +++++++++++++++ frontend/src/pages/LotsPage.css | 11 - frontend/src/pages/LotsPage.jsx | 79 ------- frontend/src/pages/OrderDetailPage.jsx | 7 +- frontend/src/pages/OrderNewPage.jsx | 6 + frontend/src/pages/OrdersPage.jsx | 18 -- frontend/src/pages/PortfolioPage.jsx | 88 ------- 20 files changed, 662 insertions(+), 300 deletions(-) create mode 100644 frontend/src/features/orders/components/LotPicker.css create mode 100644 frontend/src/features/orders/components/LotPicker.jsx create mode 100644 frontend/src/features/orders/hooks/useLotPicker.js create mode 100644 frontend/src/pages/InvestPage.css create mode 100644 frontend/src/pages/InvestPage.jsx delete mode 100644 frontend/src/pages/LotsPage.css delete mode 100644 frontend/src/pages/LotsPage.jsx delete mode 100644 frontend/src/pages/OrdersPage.jsx delete mode 100644 frontend/src/pages/PortfolioPage.jsx diff --git a/frontend/src/features/lots/api/lotsApi.js b/frontend/src/features/lots/api/lotsApi.js index 5eba4f1..8b418e3 100644 --- a/frontend/src/features/lots/api/lotsApi.js +++ b/frontend/src/features/lots/api/lotsApi.js @@ -1,12 +1,10 @@ // S8 tax lots — GET /lots (backend/app/views/lots.py). Wire fields (Money/Units/Price strings, // snake_case) pass straight through with zero camelCase mapping layer, matching feesApi.js's -// convention. No server-side sort/filter/pagination params exist — the endpoint returns every lot -// for the customer in one response (acquired_at ASC, id ASC), capped by its own 60/min rate limit. +// convention. No server-side sort/filter exists (client-side only, useLots.js) — pagination +// (`?cursor=&limit=`) is the only server-side list param, capped by its own 60/min rate limit. import { apiClient } from '../../../services/apiClient.js' +import { paginationQuery } from '../../../utils/paginationQuery.js' export const lotsApi = { - list: async () => { - const data = await apiClient.get('/lots') - return data.lots - }, + list: (pagination) => apiClient.get(`/lots${paginationQuery(pagination)}`), } diff --git a/frontend/src/features/lots/components/LotTable.jsx b/frontend/src/features/lots/components/LotTable.jsx index 5a078ef..df16a6a 100644 --- a/frontend/src/features/lots/components/LotTable.jsx +++ b/frontend/src/features/lots/components/LotTable.jsx @@ -1,8 +1,11 @@ import Decimal from 'decimal.js' import { Fragment } from 'react' +import { Button } from '../../../components/Button' +import { ErrorState } from '../../../components/ErrorState' import { Icon } from '../../../components/Icon' import { Table } from '../../../components/Table' import { UnitsValue } from '../../../components/UnitsValue' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { formatMoney } from '../../../utils/format.js' import { lotStatus } from '../utils/lotSummary.js' import { HoldingPeriodBadge } from './HoldingPeriodBadge.jsx' @@ -11,103 +14,136 @@ import { LotStatusBadge } from './LotStatusBadge.jsx' import { ProvisionalBadge } from './ProvisionalBadge.jsx' import './LotTable.css' -const COLUMN_COUNT = 7 +const COLUMN_COUNT = 8 /** Presentational — `lots` arrives already filtered/sorted from `useLots()`'s `visibleLots` - * (lifted up into `LotsPage.jsx`, which owns the fetch status). `sort`/`onSort` wire the header's + * (lifted up into `InvestPage.jsx`'s lots section, which owns the fetch status). `sort`/`onSort` wire the header's * own `sortable` prop straight through; `expandedId`/`onToggleExpand` stay page-owned so the page - * can decide when to collapse (e.g. on refetch). */ -export function LotTable({ lots, sort, onSort, expandedId, onToggleExpand }) { + * can decide when to collapse (e.g. on refetch). `hasMore`/`isLoadingMore`/`loadMoreError`/ + * `onLoadMore` wire `useLots()`'s pagination straight through too — a failed `loadMore` shows an + * inline retry below the table without discarding the rows already on screen. */ +export function LotTable({ + lots, + sort, + onSort, + expandedId, + onToggleExpand, + hasMore, + isLoadingMore, + loadMoreError, + onLoadMore, +}) { const sortDirectionFor = (column) => (sort.column === column ? sort.direction : undefined) return ( - - - onSort('symbol')}> - Security - - Status - onSort('quantity')}> - Quantity - - onSort('adjustedBasis')} - > - Adjusted basis - - onSort('marketValue')} - > - Market value - - onSort('unrealizedGainLoss')} - > - Unrealized gain/loss - -   - - - {lots.map((lot) => { - const status = lotStatus(lot) - const isExpanded = expandedId === lot.id - const gainLoss = lot.unrealized_gain_loss === null ? null : new Decimal(lot.unrealized_gain_loss) +
+
+ + onSort('symbol')}> + Security + + Status + onSort('quantity')}> + Quantity + + onSort('adjustedBasis')} + > + Adjusted basis + + onSort('marketValue')} + > + Market value + + onSort('unrealizedGainLoss')} + > + Unrealized gain/loss + + Wash sale disallowed +   + + + {lots.map((lot) => { + const status = lotStatus(lot) + const isExpanded = expandedId === lot.id + const gainLoss = lot.unrealized_gain_loss === null ? null : new Decimal(lot.unrealized_gain_loss) - return ( - - onToggleExpand(lot.id)} aria-expanded={isExpanded}> - - - {lot.symbol} - {lot.is_provisional && } - {status !== 'closed' && } - - - - - - - - - - {formatMoney(lot.adjusted_basis)} - - - {lot.market_value === null ? '—' : formatMoney(lot.market_value)} - - - {gainLoss === null ? ( - '—' - ) : ( - - {formatMoney(gainLoss)} + return ( + + onToggleExpand(lot.id)} aria-expanded={isExpanded}> + + + {lot.symbol} + {lot.is_provisional && } + {status !== 'closed' && } - )} - - - - {isExpanded && ( - - - - )} - - ) - })} - -
- -
+ + + + + + + + + {formatMoney(lot.adjusted_basis)} + + + {lot.market_value === null ? '—' : formatMoney(lot.market_value)} + + + {gainLoss === null ? ( + '—' + ) : ( + + {formatMoney(gainLoss)} + + )} + + + {formatMoney(lot.wash_sale_disallowed)} + + + + {isExpanded && ( + + + + + + )} + + ) + })} + + + {loadMoreError ? ( + + ) : ( + hasMore && ( + + ) + )} +
) } diff --git a/frontend/src/features/orders/api/ordersApi.js b/frontend/src/features/orders/api/ordersApi.js index b861375..6efacc8 100644 --- a/frontend/src/features/orders/api/ordersApi.js +++ b/frontend/src/features/orders/api/ordersApi.js @@ -1,7 +1,8 @@ import { apiClient } from '../../../services/apiClient.js' +import { paginationQuery } from '../../../utils/paginationQuery.js' export const ordersApi = { - list: () => apiClient.get('/orders'), + list: (pagination) => apiClient.get(`/orders${paginationQuery(pagination)}`), get: (orderId) => apiClient.get(`/orders/${orderId}`), create: (payload, idempotencyKey) => apiClient.post('/orders', payload, { idempotencyKey }), // No request body needed. diff --git a/frontend/src/features/orders/components/LotPicker.css b/frontend/src/features/orders/components/LotPicker.css new file mode 100644 index 0000000..437ec25 --- /dev/null +++ b/frontend/src/features/orders/components/LotPicker.css @@ -0,0 +1,53 @@ +/* Spans both columns of OrderForm.css's grid, same as the preview/error/submit blocks. */ +.tu-lot-picker { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-3); + background: var(--color-surface-sunken); + border-radius: var(--radius-sm); +} + +.tu-lot-picker__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + +.tu-lot-picker__title { + font-weight: var(--font-weight-body-medium); + color: var(--color-text); +} + +.tu-lot-picker__hint, +.tu-lot-picker__empty { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +.tu-lot-picker__checkbox { + width: 16px; + height: 16px; +} + +.tu-lot-picker__gain { + color: var(--color-success); +} + +.tu-lot-picker__loss { + color: var(--color-error); +} + +.tu-lot-picker__coverage { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +.tu-lot-picker__coverage--short { + color: var(--color-error); +} diff --git a/frontend/src/features/orders/components/LotPicker.jsx b/frontend/src/features/orders/components/LotPicker.jsx new file mode 100644 index 0000000..928adfe --- /dev/null +++ b/frontend/src/features/orders/components/LotPicker.jsx @@ -0,0 +1,115 @@ +import Decimal from 'decimal.js' +import { Button } from '../../../components/Button' +import { ErrorState } from '../../../components/ErrorState' +import { Icon } from '../../../components/Icon' +import { Skeleton } from '../../../components/Skeleton' +import { Table } from '../../../components/Table' +import { UnitsValue } from '../../../components/UnitsValue' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { formatDate, formatMoney, formatUnitsString } from '../../../utils/format.js' +import './LotPicker.css' + +/** + * Sell-only specific-ID lot designation (FR-20/ADR-4) -- shown by `OrderForm` only when + * `side === 'sell'`. Presentational: `candidates`/`selectedLotIds`/`selectedTotal`/`isShort` all + * arrive from `useLotPicker`, which owns the fetch and the coverage math. Leaving every row + * unchecked is a valid, default state (FIFO) -- there is no "select all" forced on the customer. + */ +export function LotPicker({ + status, + error, + onRetry, + candidates, + selectedLotIds, + onToggleLot, + onClear, + selectedTotal, + requested, + isShort, +}) { + if (status === 'idle' || status === 'loading') { + return + } + + if (status === 'error') { + return + } + + return ( +
+
+
+

Choose specific lots (optional)

+

Leave every lot unchecked to sell FIFO (oldest lots first) instead.

+
+ {selectedLotIds.length > 0 && ( + + )} +
+ + {candidates.length === 0 ? ( +

This security has no open lots to designate -- the order will sell FIFO.

+ ) : ( + <> + + +   + Remaining + Acquired + Unrealized gain/loss + + + {candidates.map((lot) => { + const isSelected = selectedLotIds.includes(lot.id) + const gainLoss = lot.unrealized_gain_loss === null ? null : new Decimal(lot.unrealized_gain_loss) + const checkboxId = `lot-picker-${lot.id}` + return ( + + + onToggleLot(lot.id)} + aria-label={`Designate lot acquired ${formatDate(lot.acquired_at)}`} + /> + + + + + + + + + {gainLoss === null ? ( + '—' + ) : ( + + {formatMoney(gainLoss)} + + )} + + + ) + })} + +
+ + {selectedLotIds.length > 0 && ( +

+ {isShort && } + Selected {formatUnitsString(selectedTotal.toString())} of{' '} + {requested ? formatUnitsString(requested.toString()) : '0'} units requested + {isShort && ' -- select more lots or clear the selection to sell FIFO'} +

+ )} + + )} +
+ ) +} diff --git a/frontend/src/features/orders/components/OrderDetail.css b/frontend/src/features/orders/components/OrderDetail.css index a2a58b3..26b93cf 100644 --- a/frontend/src/features/orders/components/OrderDetail.css +++ b/frontend/src/features/orders/components/OrderDetail.css @@ -31,6 +31,17 @@ margin: 0 0 var(--space-3); } +.tu-order-detail__lot-ids { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-1); + font-family: var(--font-family-mono, monospace); + font-size: var(--font-size-caption); +} + .tu-order-detail__not-found { color: var(--color-text-secondary); padding: var(--space-8) 0; diff --git a/frontend/src/features/orders/components/OrderDetail.jsx b/frontend/src/features/orders/components/OrderDetail.jsx index a1d2d22..30b3128 100644 --- a/frontend/src/features/orders/components/OrderDetail.jsx +++ b/frontend/src/features/orders/components/OrderDetail.jsx @@ -65,6 +65,22 @@ export function OrderDetail({ orderId }) {
Placed
{formatDateTime(order.created_at)}
+ {order.side === 'sell' && ( +
+
Lot designation
+
+ {order.designated_lot_ids && order.designated_lot_ids.length > 0 ? ( +
    + {order.designated_lot_ids.map((lotId) => ( +
  • {lotId}
  • + ))} +
+ ) : ( + 'FIFO (no specific lots designated)' + )} +
+
+ )} diff --git a/frontend/src/features/orders/components/OrderForm.jsx b/frontend/src/features/orders/components/OrderForm.jsx index e5cfb6d..9fa4a5d 100644 --- a/frontend/src/features/orders/components/OrderForm.jsx +++ b/frontend/src/features/orders/components/OrderForm.jsx @@ -7,11 +7,13 @@ import { Input } from '../../../components/Input' import { Select } from '../../../components/Select' import { Skeleton } from '../../../components/Skeleton' import { useToast } from '../../../components/Toast' -import { useSecurities } from '../../portfolio/hooks/useSecurities.js' +import { useSecurities } from '../../securities/hooks/useSecurities.js' import { formatMoney } from '../../../utils/format.js' import { getOrdersErrorMessage } from '../ordersErrorMessage.js' +import { useLotPicker } from '../hooks/useLotPicker.js' import { usePlaceOrder } from '../hooks/usePlaceOrder.js' import { parseQuantity } from '../parseQuantity.js' +import { LotPicker } from './LotPicker.jsx' import './OrderForm.css' export function OrderForm() { @@ -27,6 +29,14 @@ export function OrderForm() { const [quantityError, setQuantityError] = useState(null) const [referencePriceError, setReferencePriceError] = useState(null) + const selectedSecurity = securities.find((security) => security.security_id === securityId) || securities[0] + // Fetched regardless of `side` (hooks can't be called conditionally) but only rendered/consulted + // for a sell -- `securityId: undefined` while `side === 'buy'` just yields an empty candidate list. + const lotPicker = useLotPicker({ + securityId: side === 'sell' ? selectedSecurity?.security_id : undefined, + requestedQuantity: quantity, + }) + if (securitiesStatus === 'idle' || securitiesStatus === 'loading') { return } @@ -39,7 +49,6 @@ export function OrderForm() { return

No tradable securities are available right now.

} - const selectedSecurity = securities.find((security) => security.security_id === securityId) || securities[0] const isSubmitting = status === 'submitting' const notional = @@ -75,15 +84,17 @@ export function OrderForm() { side, quantity: parsedQuantity, referencePrice: trimmedReferencePrice, + lotIds: side === 'sell' ? lotPicker.selectedLotIds : undefined, }) .then((order) => { showToast({ message: 'Order placed.', tone: 'success' }) - navigate(`/orders/${order.id}`) + navigate(`/invest/orders/${order.id}`) }) .catch(() => {}) } const submitError = status === 'error' ? getOrdersErrorMessage(error) : null + const isSubmitDisabled = isSubmitting || (side === 'sell' && lotPicker.isShort) return (
@@ -124,6 +135,20 @@ export function OrderForm() { error={referencePriceError} required /> + {side === 'sell' && ( + + )} {notional && (
Estimated notional @@ -135,7 +160,7 @@ export function OrderForm() { {submitError}

)} - diff --git a/frontend/src/features/orders/hooks/useLotPicker.js b/frontend/src/features/orders/hooks/useLotPicker.js new file mode 100644 index 0000000..9f9afe5 --- /dev/null +++ b/frontend/src/features/orders/hooks/useLotPicker.js @@ -0,0 +1,72 @@ +import Decimal from 'decimal.js' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useLots } from '../../lots/hooks/useLots.js' + +/** + * Specific-ID tax-lot designation for a sell order (FR-20/ADR-4) -- a client-side UX nicety that + * mirrors Task 3's placement-time validation so the customer sees the shortfall before submitting; + * the server-side check in `LotDesignationService` remains the actual authority, this is not a + * substitute for it. Sourced from `useLots()` (Task 5), filtered to `securityId`'s own lots with + * `quantity_remaining > 0` -- a lot with nothing left to sell is never a valid designation target. + * + * An empty selection is a valid, first-class state (it means FIFO, ADR-4's default), never a + * forced choice -- `isShort` only trips once the customer has actually started picking lots. + */ +export function useLotPicker({ securityId, requestedQuantity }) { + const lots = useLots() + const [selectedLotIds, setSelectedLotIds] = useState([]) + + // A different security invalidates any prior selection -- those lot ids belong to another + // security entirely and would fail Task 3's own `unknown_tax_lot` check if resubmitted. + useEffect(() => { + setSelectedLotIds([]) + }, [securityId]) + + const candidates = useMemo( + () => + lots.lots.filter( + (lot) => lot.security_id === securityId && new Decimal(lot.quantity_remaining).greaterThan(0), + ), + [lots.lots, securityId], + ) + + const toggleLot = useCallback((lotId) => { + setSelectedLotIds((prev) => (prev.includes(lotId) ? prev.filter((id) => id !== lotId) : [...prev, lotId])) + }, []) + + const clearSelection = useCallback(() => setSelectedLotIds([]), []) + + const selectedTotal = useMemo( + () => + candidates + .filter((lot) => selectedLotIds.includes(lot.id)) + .reduce((sum, lot) => sum.plus(lot.quantity_remaining), new Decimal(0)), + [candidates, selectedLotIds], + ) + + const requested = useMemo(() => { + if (!requestedQuantity) return null + try { + const decimal = new Decimal(requestedQuantity) + return decimal.isFinite() && decimal.greaterThan(0) ? decimal : null + } catch { + return null + } + }, [requestedQuantity]) + + // Only a non-empty selection can be "short" -- an empty one is FIFO, not a shortfall. + const isShort = selectedLotIds.length > 0 && requested !== null && selectedTotal.lessThan(requested) + + return { + status: lots.status, + error: lots.error, + refetch: lots.refetch, + candidates, + selectedLotIds, + toggleLot, + clearSelection, + selectedTotal, + requested, + isShort, + } +} diff --git a/frontend/src/features/orders/hooks/useOrder.js b/frontend/src/features/orders/hooks/useOrder.js index dc373cd..c23d36c 100644 --- a/frontend/src/features/orders/hooks/useOrder.js +++ b/frontend/src/features/orders/hooks/useOrder.js @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useLiveRefetch } from '../../../hooks/useLiveRefetch.js' import { ordersApi } from '../api/ordersApi.js' const IDLE = { status: 'idle', order: null, events: [], error: null } @@ -16,6 +17,8 @@ export function useOrder(orderId) { } }, [orderId]) + useLiveRefetch(['order_updated'], refetch) + useEffect(() => { refetch() }, [refetch]) diff --git a/frontend/src/features/orders/hooks/usePlaceOrder.js b/frontend/src/features/orders/hooks/usePlaceOrder.js index d42badc..959471b 100644 --- a/frontend/src/features/orders/hooks/usePlaceOrder.js +++ b/frontend/src/features/orders/hooks/usePlaceOrder.js @@ -8,12 +8,20 @@ export function usePlaceOrder() { const { getKey, reset, resetIfFinal } = useIdempotencyKey() const placeOrder = useCallback( - async ({ securityId, side, quantity, referencePrice }) => { + async ({ securityId, side, quantity, referencePrice, lotIds }) => { setStatus('submitting') setError(null) try { const data = await ordersApi.create( - { security_id: securityId, side, quantity, reference_price: referencePrice }, + { + security_id: securityId, + side, + quantity, + reference_price: referencePrice, + // Specific-ID lot designation is sell-only (Task 3's contract) -- omitting the key + // entirely (not sending an empty array) means FIFO. + ...(side === 'sell' && lotIds?.length > 0 ? { lot_ids: lotIds } : {}), + }, getKey(), ) setStatus('submitted') diff --git a/frontend/src/features/orders/ordersErrorMessage.js b/frontend/src/features/orders/ordersErrorMessage.js index 1593387..2155697 100644 --- a/frontend/src/features/orders/ordersErrorMessage.js +++ b/frontend/src/features/orders/ordersErrorMessage.js @@ -10,6 +10,12 @@ const SPECIFIC_MESSAGES = { 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.", + lot_designation_not_allowed_for_buy: 'Specific lots can only be designated on a sell order.', + empty_lot_designation: 'Select at least one lot, or clear the selection to sell FIFO.', + duplicate_lot_id: 'The same lot was selected more than once.', + too_many_designated_lots: 'Too many lots were designated for this order.', + unknown_tax_lot: "One of the selected lots couldn't be found -- try refreshing the lot list.", + insufficient_designated_lots: "The selected lots don't cover the full quantity requested.", } export function getOrdersErrorMessage(error) { diff --git a/frontend/src/pages/InvestPage.css b/frontend/src/pages/InvestPage.css new file mode 100644 index 0000000..ca7d4f6 --- /dev/null +++ b/frontend/src/pages/InvestPage.css @@ -0,0 +1,17 @@ +.tu-invest-page__lots-summary-skeleton { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--space-4); +} + +@media (min-width: 480px) { + .tu-invest-page__lots-summary-skeleton { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.tu-invest-page__lots { + display: flex; + flex-direction: column; + gap: var(--space-4); +} diff --git a/frontend/src/pages/InvestPage.jsx b/frontend/src/pages/InvestPage.jsx new file mode 100644 index 0000000..28c6827 --- /dev/null +++ b/frontend/src/pages/InvestPage.jsx @@ -0,0 +1,186 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { Button } from '../components/Button' +import { Card } from '../components/Card' +import { EmptyState } from '../components/EmptyState' +import { ErrorState } from '../components/ErrorState' +import { Skeleton } from '../components/Skeleton' +import { useToast } from '../components/Toast' +import { useSession } from '../contexts/SessionContext.jsx' +import { AssignmentPrompt } from '../features/portfolio/components/AssignmentPrompt.jsx' +import { ModelDetail } from '../features/portfolio/components/ModelDetail.jsx' +import { useAssignment } from '../features/portfolio/hooks/useAssignment.js' +import { useModels } from '../features/portfolio/hooks/useModels.js' +import { LotFilterToolbar } from '../features/lots/components/LotFilterToolbar.jsx' +import { LotSummary } from '../features/lots/components/LotSummary.jsx' +import { LotTable } from '../features/lots/components/LotTable.jsx' +import { useLots } from '../features/lots/hooks/useLots.js' +import { OrderList } from '../features/orders/components/OrderList.jsx' +import { getErrorMessage } from '../utils/apiErrorMessage.js' +import './InvestPage.css' +import './PageLayout.css' + +/** Model assignment/detail -- formerly the whole of `PortfolioPage.jsx`. Both the assignment and + * the model list must be loaded before an already-assigned customer can be told from a not-yet- + * assigned one (deciding early off an empty, still-loading `models` array flashes the "choose a + * model" prompt on every page load). */ +function PortfolioSection() { + const { principal } = useSession() + const assignment = useAssignment(principal.id) + const { status: modelsStatus, models, error: modelsError, refetch: refetchModels } = useModels() + const { showToast } = useToast() + + const isLoading = + assignment.status === 'idle' || + assignment.status === 'loading' || + modelsStatus === 'idle' || + modelsStatus === 'loading' + + if (isLoading) { + return + } + + if (assignment.status === 'error') { + return + } + + if (modelsStatus === 'error') { + return + } + + const assignedModel = assignment.assignment + ? models.find((model) => model.id === assignment.assignment.model_portfolio_id) + : null + + const handleSelect = (modelId) => { + const chosen = models.find((model) => model.id === modelId) + return assignment + .assign(modelId) + .then(() => { + showToast({ message: chosen ? `You're now assigned to ${chosen.name}.` : 'Model assigned.', tone: 'success' }) + }) + .catch(() => {}) + } + + return assignedModel ? ( + + ) : ( + + ) +} + +function LotsSectionSkeleton() { + return ( + <> +
+ + + + + + + + +
+ + + + + ) +} + +/** Formerly the whole of `LotsPage.jsx` -- `useLots` owns the fetch plus client-side filter/sort + * state (no server-side sort/filter exists for `GET /lots`). */ +function LotsSection() { + const { + status, + lots, + visibleLots, + nextCursor, + statusFilter, + setStatusFilter, + sort, + toggleSort, + error, + refetch, + loadMore, + } = useLots() + const [expandedId, setExpandedId] = useState(null) + + const toggleExpand = (lotId) => setExpandedId((prev) => (prev === lotId ? null : lotId)) + + if (status === 'idle' || status === 'loading') { + return + } + + if (status === 'error' && lots.length === 0) { + return + } + + if (lots.length === 0) { + return + } + + return ( +
+ + + {visibleLots.length === 0 ? ( + + ) : ( + + )} +
+ ) +} + +/** The Invest destination (structure.md/Task 9): model portfolio, orders, and tax lots -- + * formerly three separate pages (`PortfolioPage`, `OrdersPage`, `LotsPage`), folded into one so + * the 5-destination nav (Task 7) has a single real screen behind `/invest`. */ +export function InvestPage() { + return ( +
+
+

Invest

+ + + +
+ +
+

Portfolio

+ +
+ +
+

Orders

+ +
+ +
+

Tax lots

+ +
+
+ ) +} diff --git a/frontend/src/pages/LotsPage.css b/frontend/src/pages/LotsPage.css deleted file mode 100644 index 6c92c0b..0000000 --- a/frontend/src/pages/LotsPage.css +++ /dev/null @@ -1,11 +0,0 @@ -.tu-lots-page__summary-skeleton { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--space-4); -} - -@media (min-width: 480px) { - .tu-lots-page__summary-skeleton { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} diff --git a/frontend/src/pages/LotsPage.jsx b/frontend/src/pages/LotsPage.jsx deleted file mode 100644 index 1e5c054..0000000 --- a/frontend/src/pages/LotsPage.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useState } from 'react' -import { Card } from '../components/Card' -import { EmptyState } from '../components/EmptyState' -import { ErrorState } from '../components/ErrorState' -import { Skeleton } from '../components/Skeleton' -import { LotFilterToolbar } from '../features/lots/components/LotFilterToolbar.jsx' -import { LotSummary } from '../features/lots/components/LotSummary.jsx' -import { LotTable } from '../features/lots/components/LotTable.jsx' -import { useLots } from '../features/lots/hooks/useLots.js' -import { getErrorMessage } from '../utils/apiErrorMessage.js' -import './LotsPage.css' -import './PageLayout.css' - -function LotsPageSkeleton() { - return ( - <> -
- - - - - - - - -
- - - - - ) -} - -export function LotsPage() { - const { status, lots, visibleLots, statusFilter, setStatusFilter, sort, toggleSort, error, refetch } = useLots() - const [expandedId, setExpandedId] = useState(null) - - const toggleExpand = (lotId) => setExpandedId((prev) => (prev === lotId ? null : lotId)) - - if (status === 'idle' || status === 'loading') { - return ( -
-

Tax lots

- -
- ) - } - - if (status === 'error') { - return ( -
-

Tax lots

- -
- ) - } - - if (lots.length === 0) { - return ( -
-

Tax lots

- -
- ) - } - - return ( -
-

Tax lots

- - - {visibleLots.length === 0 ? ( - - ) : ( - - )} -
- ) -} diff --git a/frontend/src/pages/OrderDetailPage.jsx b/frontend/src/pages/OrderDetailPage.jsx index c06b3fc..fe675a2 100644 --- a/frontend/src/pages/OrderDetailPage.jsx +++ b/frontend/src/pages/OrderDetailPage.jsx @@ -1,4 +1,5 @@ -import { useParams } from 'react-router-dom' +import { Link, useParams } from 'react-router-dom' +import { Icon } from '../components/Icon' import { OrderDetail } from '../features/orders/components/OrderDetail.jsx' import './PageLayout.css' @@ -6,6 +7,10 @@ export function OrderDetailPage() { const { orderId } = useParams() return (
+ + + Back to Invest +

Order detail

diff --git a/frontend/src/pages/OrderNewPage.jsx b/frontend/src/pages/OrderNewPage.jsx index 8f2860c..a8084b0 100644 --- a/frontend/src/pages/OrderNewPage.jsx +++ b/frontend/src/pages/OrderNewPage.jsx @@ -1,4 +1,6 @@ +import { Link } from 'react-router-dom' import { Card } from '../components/Card' +import { Icon } from '../components/Icon' import { OrderForm } from '../features/orders/components/OrderForm.jsx' import './OrderNewPage.css' import './PageLayout.css' @@ -6,6 +8,10 @@ import './PageLayout.css' export function OrderNewPage() { return (
+ + + Back to Invest +

Place an order

diff --git a/frontend/src/pages/OrdersPage.jsx b/frontend/src/pages/OrdersPage.jsx deleted file mode 100644 index b5a6839..0000000 --- a/frontend/src/pages/OrdersPage.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Link } from 'react-router-dom' -import { Button } from '../components/Button' -import { OrderList } from '../features/orders/components/OrderList.jsx' -import './PageLayout.css' - -export function OrdersPage() { - return ( -
-
-

Orders

- - - -
- -
- ) -} diff --git a/frontend/src/pages/PortfolioPage.jsx b/frontend/src/pages/PortfolioPage.jsx deleted file mode 100644 index 2899828..0000000 --- a/frontend/src/pages/PortfolioPage.jsx +++ /dev/null @@ -1,88 +0,0 @@ -import { ErrorState } from '../components/ErrorState' -import { Skeleton } from '../components/Skeleton' -import { useToast } from '../components/Toast' -import { useSession } from '../contexts/SessionContext.jsx' -import { AssignmentPrompt } from '../features/portfolio/components/AssignmentPrompt.jsx' -import { ModelDetail } from '../features/portfolio/components/ModelDetail.jsx' -import { useAssignment } from '../features/portfolio/hooks/useAssignment.js' -import { useModels } from '../features/portfolio/hooks/useModels.js' -import { getErrorMessage } from '../utils/apiErrorMessage.js' -import './PageLayout.css' - -export function PortfolioPage() { - const { principal } = useSession() - const assignment = useAssignment(principal.id) - const { status: modelsStatus, models, error: modelsError, refetch: refetchModels } = useModels() - const { showToast } = useToast() - - // Both the assignment and the model list must be loaded before we can tell an already-assigned - // customer from a not-yet-assigned one — deciding early (e.g. from an empty, still-loading - // `models` array) is what previously flashed the "choose a model" prompt on every page load. - const isLoading = - assignment.status === 'idle' || - assignment.status === 'loading' || - modelsStatus === 'idle' || - modelsStatus === 'loading' - - if (isLoading) { - return ( -
-

Portfolio

- -
- ) - } - - if (assignment.status === 'error') { - return ( -
-

Portfolio

- -
- ) - } - - if (modelsStatus === 'error') { - return ( -
-

Portfolio

- -
- ) - } - - const assignedModel = assignment.assignment - ? models.find((model) => model.id === assignment.assignment.model_portfolio_id) - : null - - const handleSelect = (modelId) => { - const chosen = models.find((model) => model.id === modelId) - return assignment - .assign(modelId) - .then(() => { - showToast({ message: chosen ? `You're now assigned to ${chosen.name}.` : 'Model assigned.', tone: 'success' }) - }) - .catch(() => {}) - } - - return ( -
-

Portfolio

- {assignedModel ? ( - - ) : ( - - )} -
- ) -} From 9e331462eb7d2f58ce7030d4286d3a4ea42f06b6 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Wed, 16 Sep 2026 12:46:50 -0700 Subject: [PATCH 11/14] feat(frontend): Money destination -- funding + fees, bank re-link handling Task 10 of the fullstack-rebuild plan. Folds `FundingPage.jsx` and `FeesPage.jsx` into a new `MoneyPage.jsx`; deposit/withdraw becomes a segmented control over one form, existing `DepositForm`/`WithdrawForm` logic preserved, presentation restyled. Finishes the `useFunding.js` split Task 8 started: cash-summary and history reading are fully removed now that `useCashSummary`/ `useFundingHistory` own them, and `useFunding.js` is deleted with every remaining import updated to the two new hooks -- no half-used old/new pair left live. `useCurrentBankLink` now handles `requires_reauth` (modeled server-side per FR-43, never handled client-side before this): renders a distinct "re-link your bank" interstitial instead of treating it as `active`, and pauses -- never silently retries or fails -- any in-flight deposit/withdraw flow that depends on the link. Cash pair (withdrawable/investable) rendered at equal visual weight per ADR 5, reusing the same presentational treatment Task 8's dashboard already uses rather than rebuilding it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p --- .../src/features/fees/utils/feeSummary.js | 8 +- .../src/features/funding/api/fundingApi.js | 3 +- .../funding/components/BankLinkCard.css | 7 + .../funding/components/BankLinkCard.jsx | 17 +- .../funding/components/CashSummary.jsx | 7 +- .../funding/components/DepositForm.jsx | 13 +- .../funding/components/WithdrawForm.jsx | 8 +- .../src/features/funding/hooks/useFunding.js | 53 ---- frontend/src/pages/FeesPage.css | 39 --- frontend/src/pages/FeesPage.jsx | 114 ------- frontend/src/pages/FundingPage.css | 31 -- frontend/src/pages/FundingPage.jsx | 146 --------- frontend/src/pages/MoneyPage.css | 73 +++++ frontend/src/pages/MoneyPage.jsx | 292 ++++++++++++++++++ 14 files changed, 410 insertions(+), 401 deletions(-) delete mode 100644 frontend/src/features/funding/hooks/useFunding.js delete mode 100644 frontend/src/pages/FeesPage.css delete mode 100644 frontend/src/pages/FeesPage.jsx delete mode 100644 frontend/src/pages/FundingPage.css delete mode 100644 frontend/src/pages/FundingPage.jsx create mode 100644 frontend/src/pages/MoneyPage.css create mode 100644 frontend/src/pages/MoneyPage.jsx diff --git a/frontend/src/features/fees/utils/feeSummary.js b/frontend/src/features/fees/utils/feeSummary.js index bd86598..b048611 100644 --- a/frontend/src/features/fees/utils/feeSummary.js +++ b/frontend/src/features/fees/utils/feeSummary.js @@ -1,9 +1,9 @@ import Decimal from 'decimal.js' -// Pure helpers for the Fees page — no React, no API calls. Kept separate from the components so -// the billing-period math and the empty-state check are each independently reasoned about and -// reusable (FeesPage.jsx needs `hasNoFeeActivity` at the page level; AccrualSummary needs -// `getBillingPeriod`). +// Pure helpers for the Money page's fees section — no React, no API calls. Kept separate from the +// components so the billing-period math and the empty-state check are each independently reasoned +// about and reusable (MoneyPage.jsx needs `hasNoFeeActivity` at the page level; AccrualSummary +// needs `getBillingPeriod`). // `GET /fees` always returns an accrual object (`FeeSummaryResponse`, backend/app/views/fees.py) // — accrual is never absent, so this is a real zero-activity check, not a falsy check. diff --git a/frontend/src/features/funding/api/fundingApi.js b/frontend/src/features/funding/api/fundingApi.js index 878930c..1a03d1f 100644 --- a/frontend/src/features/funding/api/fundingApi.js +++ b/frontend/src/features/funding/api/fundingApi.js @@ -1,4 +1,5 @@ import { apiClient } from '../../../services/apiClient.js' +import { paginationQuery } from '../../../utils/paginationQuery.js' export const fundingApi = { deposit: (payload, idempotencyKey) => apiClient.post('/funding/deposits', payload, { idempotencyKey }), @@ -6,5 +7,5 @@ export const fundingApi = { getCurrentBankLink: () => apiClient.get('/funding/bank-links/current'), // design-system.md §8.2 getCashSummary: () => apiClient.get('/funding/cash-summary'), - getFundingHistory: () => apiClient.get('/funding/history'), + getFundingHistory: (pagination) => apiClient.get(`/funding/history${paginationQuery(pagination)}`), } diff --git a/frontend/src/features/funding/components/BankLinkCard.css b/frontend/src/features/funding/components/BankLinkCard.css index 46424fb..0f8798d 100644 --- a/frontend/src/features/funding/components/BankLinkCard.css +++ b/frontend/src/features/funding/components/BankLinkCard.css @@ -6,6 +6,13 @@ flex-wrap: wrap; } +/* Same left-accent-rule vocabulary as DunningBanner.css's warning state -- this card is the + deposit/withdraw-blocking interstitial FR-43 requires, not a routine status row. */ +.tu-bank-link-card--reauth { + background: var(--color-warning-subtle); + border-left: 3px solid var(--color-warning); +} + .tu-bank-link-card__info { display: flex; flex-direction: column; diff --git a/frontend/src/features/funding/components/BankLinkCard.jsx b/frontend/src/features/funding/components/BankLinkCard.jsx index c6bca43..3873960 100644 --- a/frontend/src/features/funding/components/BankLinkCard.jsx +++ b/frontend/src/features/funding/components/BankLinkCard.jsx @@ -8,10 +8,10 @@ import { LinkBankButton } from './LinkBankButton.jsx' import './BankLinkCard.css' /** - * Bank-link status in the funding screen's own voice — replaces the borrowed onboarding copy that + * Bank-link status in the Money page's own voice — replaces the borrowed onboarding copy that * used to sit inline in `FundingPage.jsx`. No unlink affordance (explicitly out of scope). * - * Presentational: `status`/`bankLink`/`error` arrive from `FundingPage`'s single + * Presentational: `status`/`bankLink`/`error` arrive from `MoneyPage`'s single * `useCurrentBankLink()` call, shared with `DepositForm`/`WithdrawForm`'s disabled-state check — * a second and third `GET /funding/bank-links/current` from those forms calling the hook * themselves would repeat, on a different endpoint, exactly the double-fetch bug this rebuild @@ -51,11 +51,18 @@ export function BankLinkCard({ customerId, status, bankLink, error, onRetry, onL } if (bankLink?.status === 'requires_reauth') { + // FR-43's distinct interstitial: a stale Plaid Item (e.g. `ITEM_LOGIN_REQUIRED`) is never + // shown as though the link were still `active` and never silently dropped -- `role="status"` + // announces the state change to a screen-reader user the moment `useCurrentBankLink` (or a + // paused deposit/withdraw submit, see DepositForm.jsx) surfaces it. return ( - +
-

Bank account

-

Your bank needs to be reconnected before you can deposit or withdraw.

+

Bank account needs reconnecting

+

+ We can no longer confirm your bank account is still valid. Deposits and withdrawals are + paused until you reconnect it -- nothing has failed, and no automatic retry will happen. +

}> Needs reconnecting diff --git a/frontend/src/features/funding/components/CashSummary.jsx b/frontend/src/features/funding/components/CashSummary.jsx index 63f64fc..0940826 100644 --- a/frontend/src/features/funding/components/CashSummary.jsx +++ b/frontend/src/features/funding/components/CashSummary.jsx @@ -5,12 +5,13 @@ import './CashSummary.css' * Design-system §8.2 — Withdrawable and Investable as two co-equal stat figures, never merged * into one "Balance." Purely presentational: `cashSummary` arrives as a prop and there is no fetch * or loading/error branch here — this component used to run its own `useCashPolicy()` call on top - * of the page's `useFunding()` fetch, which was the source of the double `GET /cash-summary` - * request per page load; the fetch now lives in `useFunding` alone (see `hooks/useFunding.js`). + * of the page's own fetch, which was the source of the double `GET /cash-summary` request per page + * load; the fetch now lives in `useCashSummary` alone (see `hooks/useCashSummary.js`), shared by + * the Dashboard and the Money page. * * `emphasize` opts one figure down to `body`/`text-secondary` for a form scoped to one policy * function (`WithdrawForm` passes `"withdrawable"` so Investable reads quieter, per ADR 5) — - * omitted (Dashboard/Funding overview usage), both stay equal weight. + * omitted (Dashboard/Money overview usage), both stay equal weight. */ export function CashSummary({ cashSummary, emphasize }) { return ( diff --git a/frontend/src/features/funding/components/DepositForm.jsx b/frontend/src/features/funding/components/DepositForm.jsx index af984c8..e54feb1 100644 --- a/frontend/src/features/funding/components/DepositForm.jsx +++ b/frontend/src/features/funding/components/DepositForm.jsx @@ -9,7 +9,7 @@ import { validateDepositAmount } from '../utils/fundingSummary.js' import { DepositLimits } from './DepositLimits.jsx' import './FundingForm.css' -/** `bankLinkLoading` covers the short window before `FundingPage`'s `useCurrentBankLink()` call +/** `bankLinkLoading` covers the short window before `MoneyPage`'s `useCurrentBankLink()` call * resolves — the form stays enabled during that window rather than flashing a "link a bank" * reason that might be wrong a moment later; a submit in that window still has the server's own * `no_active_bank_link` response as a fallback (`fundingErrorMessage.js`). */ @@ -20,7 +20,7 @@ function disabledReasonFor(bankLink, bankLinkLoading) { return null } -export function DepositForm({ customerId, cashSummary, bankLink, bankLinkLoading, onSubmitted }) { +export function DepositForm({ customerId, cashSummary, bankLink, bankLinkLoading, onSubmitted, onBankReauthRequired }) { const { status, error, deposit } = useDeposit(customerId) const { showToast } = useToast() const [amount, setAmount] = useState('') @@ -50,7 +50,14 @@ export function DepositForm({ customerId, cashSummary, bankLink, bankLinkLoading setAmount('') onSubmitted?.() }) - .catch(() => {}) + .catch((err) => { + // FR-43: a stale bank Item surfaces here as `bank_reauth_required` — pause the flow by + // re-syncing the bank-link status (which flips `disabledReasonFor` above to the + // reconnect-required branch) rather than leaving this amount to fail the same way again on + // a second submit. `amount` deliberately stays in this still-mounted component's state so + // it's still here once the customer reconnects. + if (err?.code === 'bank_reauth_required') onBankReauthRequired?.() + }) } const displayError = validationMessage || (status === 'error' ? getFundingErrorMessage(error) : null) diff --git a/frontend/src/features/funding/components/WithdrawForm.jsx b/frontend/src/features/funding/components/WithdrawForm.jsx index ee7df30..0b0ea4e 100644 --- a/frontend/src/features/funding/components/WithdrawForm.jsx +++ b/frontend/src/features/funding/components/WithdrawForm.jsx @@ -21,7 +21,7 @@ function disabledReasonFor(bankLink, bankLinkLoading, cashSummary) { return null } -export function WithdrawForm({ customerId, cashSummary, bankLink, bankLinkLoading, onSubmitted }) { +export function WithdrawForm({ customerId, cashSummary, bankLink, bankLinkLoading, onSubmitted, onBankReauthRequired }) { const { status, error, withdraw } = useWithdraw(customerId) const { showToast } = useToast() const [amount, setAmount] = useState('') @@ -51,7 +51,11 @@ export function WithdrawForm({ customerId, cashSummary, bankLink, bankLinkLoadin setAmount('') onSubmitted?.() }) - .catch(() => {}) + .catch((err) => { + // See DepositForm.jsx's identical handler: FR-43 pauses the flow on a stale bank Item + // instead of leaving this amount to fail the same way again on a second submit. + if (err?.code === 'bank_reauth_required') onBankReauthRequired?.() + }) } const displayError = validationMessage || (status === 'error' ? getFundingErrorMessage(error) : null) diff --git a/frontend/src/features/funding/hooks/useFunding.js b/frontend/src/features/funding/hooks/useFunding.js deleted file mode 100644 index 91cff44..0000000 --- a/frontend/src/features/funding/hooks/useFunding.js +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { fundingApi } from '../api/fundingApi.js' - -const IDLE = { status: 'idle', cashSummary: null, entries: [], error: null, isRefreshing: false } - -/** - * Owns the Funding screen's two core fetches (`GET /cash-summary`, `GET /history`) as one - * container — the double-fetch bug this replaces came from `CashSummary` running its own second - * `/cash-summary` call on top of this same data (removed; `CashSummary` is now presentational). - * - * `refetch()` is the initial/retry path (`status` flips to `'loading'`, page renders its skeleton - * again). `refresh()` is the post-submit path (a successful deposit/withdrawal) — it re-fetches - * both endpoints while keeping `status: 'loaded'` and flipping `isRefreshing` instead, so a - * successful submit updates the balance/history in place rather than blanking the page back to - * skeletons. - */ -export function useFunding() { - const [state, setState] = useState(IDLE) - - const load = useCallback(async () => { - const [cashSummary, history] = await Promise.all([fundingApi.getCashSummary(), fundingApi.getFundingHistory()]) - return { cashSummary, entries: history.entries } - }, []) - - const refetch = useCallback(async () => { - setState((prev) => ({ ...prev, status: 'loading', error: null })) - try { - const { cashSummary, entries } = await load() - setState({ status: 'loaded', cashSummary, entries, error: null, isRefreshing: false }) - } catch (error) { - setState({ status: 'error', cashSummary: null, entries: [], error, isRefreshing: false }) - } - }, [load]) - - const refresh = useCallback(async () => { - setState((prev) => ({ ...prev, isRefreshing: true })) - try { - const { cashSummary, entries } = await load() - setState((prev) => ({ ...prev, status: 'loaded', cashSummary, entries, error: null, isRefreshing: false })) - } catch { - // The deposit/withdrawal itself already succeeded — only the re-fetch failed. Keep the - // last-known-good balance/history on screen rather than blanking it over a transient error; - // the next refetch()/refresh() will catch up. - setState((prev) => ({ ...prev, isRefreshing: false })) - } - }, [load]) - - useEffect(() => { - refetch() - }, [refetch]) - - return { ...state, refetch, refresh } -} diff --git a/frontend/src/pages/FeesPage.css b/frontend/src/pages/FeesPage.css deleted file mode 100644 index b406d00..0000000 --- a/frontend/src/pages/FeesPage.css +++ /dev/null @@ -1,39 +0,0 @@ -.tu-fees-page__subtitle { - margin: var(--space-1) 0 0; - font-size: var(--font-size-body); - color: var(--color-text-secondary); -} - -/* The hero + progress run full width above this; the high-water mark and charge history are two - supporting reads that sit side by side once there's room. design-system.md §5: "Below md, the - two-column dashboard stacks to one column." */ -.tu-fees-page__columns { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--space-6); - align-items: start; -} - -/* 768px literal: --breakpoint-md exists in tokens.css but custom properties aren't usable inside - a media-query condition (same approach the old PaymentMethodForm.css already used at 560px). */ -@media (min-width: 768px) { - .tu-fees-page__columns { - /* The high-water mark is a single figure + a sentence; charge history is a table and takes - the slack. minmax(0, ...) on both tracks lets the table's own overflow-x wrapper - (Table.css's .tu-table-wrapper) scroll instead of blowing the grid out. */ - grid-template-columns: minmax(240px, 1fr) minmax(0, 2fr); - } -} - -.tu-fees-page__payment { - max-width: var(--reading-max-width); -} - -/* This heading is only ever reached via focusPaymentMethod()'s programmatic .focus() (tabIndex=-1 - takes it out of the natural tab order) -- :focus, not :focus-visible, so the ring reliably shows - regardless of the browser's keyboard-vs-pointer heuristic for a JS-driven focus() call. */ -.tu-fees-page__payment .tu-page__section-title:focus { - outline: 2px solid var(--color-accent); - outline-offset: 2px; - border-radius: var(--radius-sm); -} diff --git a/frontend/src/pages/FeesPage.jsx b/frontend/src/pages/FeesPage.jsx deleted file mode 100644 index 4b79dcb5..0000000 --- a/frontend/src/pages/FeesPage.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useCallback, useRef, useState } from 'react' -import { Card } from '../components/Card' -import { EmptyState } from '../components/EmptyState' -import { ErrorState } from '../components/ErrorState' -import { Skeleton } from '../components/Skeleton' -import { useSession } from '../contexts/SessionContext.jsx' -import { AccrualSummary } from '../features/fees/components/AccrualSummary.jsx' -import { ChargeHistory } from '../features/fees/components/ChargeHistory.jsx' -import { DunningBanner } from '../features/fees/components/DunningBanner.jsx' -import { HighWaterMarkCard } from '../features/fees/components/HighWaterMarkCard.jsx' -import { PaymentMethodForm } from '../features/fees/components/PaymentMethodForm.jsx' -import { useFees } from '../features/fees/hooks/useFees.js' -import { getBillingPeriod, hasNoFeeActivity } from '../features/fees/utils/feeSummary.js' -import { getErrorMessage } from '../utils/apiErrorMessage.js' -import './FeesPage.css' -import './PageLayout.css' - -function FeesPageSkeleton() { - return ( - <> - - - - - - -
- - - - - - - - - -
- - ) -} - -export function FeesPage() { - const { principal } = useSession() - const { status, accrual, charges, dunning, paymentMethod, error, refetch, setPaymentMethod } = useFees() - const [billingPeriod] = useState(getBillingPeriod) - const paymentSectionRef = useRef(null) - const paymentHeadingRef = useRef(null) - - const focusPaymentMethod = useCallback(() => { - const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches - paymentSectionRef.current?.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' }) - paymentHeadingRef.current?.focus() - }, []) - - if (status === 'idle' || status === 'loading') { - return ( -
-

Fees

- -
- ) - } - - if (status === 'error') { - return ( -
-

Fees

- -
- ) - } - - const isEmpty = hasNoFeeActivity(accrual, charges) - - return ( -
-
-

Fees

-

- A performance fee accrues daily on gains above your high-water mark and is charged monthly. -

-
- - {isEmpty ? ( - - ) : ( - <> - -
-
-

High-water mark

- -
-
-

Charge history

- -
-
- - )} -
-

- Payment method -

- - - -
-
- ) -} diff --git a/frontend/src/pages/FundingPage.css b/frontend/src/pages/FundingPage.css deleted file mode 100644 index 7f2b992..0000000 --- a/frontend/src/pages/FundingPage.css +++ /dev/null @@ -1,31 +0,0 @@ -.tu-funding-page__subtitle { - margin: var(--space-1) 0 0; - font-size: var(--font-size-body); - color: var(--color-text-secondary); -} - -.tu-funding-page__forms { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--space-6); - align-items: start; -} - -/* 768px literal: --breakpoint-md exists in tokens.css but custom properties aren't usable inside a - media-query condition — matching FeesPage.css's own note on the same constraint. */ -@media (min-width: 768px) { - .tu-funding-page__forms { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -/* This heading is only ever reached via OutstandingBalanceBanner's onMakeDeposit -> - focusDepositForm()'s programmatic .focus() call (tabIndex=-1 takes it out of the natural tab - order) — matching FeesPage.css's focusPaymentMethod() precedent, :focus (not :focus-visible) is - used so the ring reliably shows regardless of the browser's keyboard-vs-pointer heuristic for a - JS-driven focus() call. */ -.tu-funding-page__deposit-section .tu-page__section-title:focus { - outline: 2px solid var(--color-accent); - outline-offset: 2px; - border-radius: var(--radius-sm); -} diff --git a/frontend/src/pages/FundingPage.jsx b/frontend/src/pages/FundingPage.jsx deleted file mode 100644 index 734e62b..0000000 --- a/frontend/src/pages/FundingPage.jsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useCallback, useMemo, useRef, useState } from 'react' -import { Card } from '../components/Card' -import { ErrorState } from '../components/ErrorState' -import { Skeleton } from '../components/Skeleton' -import { useSession } from '../contexts/SessionContext.jsx' -import { BankLinkCard } from '../features/funding/components/BankLinkCard.jsx' -import { CashSummary } from '../features/funding/components/CashSummary.jsx' -import { DepositForm } from '../features/funding/components/DepositForm.jsx' -import { FundingHistoryTable } from '../features/funding/components/FundingHistoryTable.jsx' -import { OutstandingBalanceBanner } from '../features/funding/components/OutstandingBalanceBanner.jsx' -import { WithdrawForm } from '../features/funding/components/WithdrawForm.jsx' -import { useCurrentBankLink } from '../features/funding/hooks/useCurrentBankLink.js' -import { useFunding } from '../features/funding/hooks/useFunding.js' -import { getErrorMessage } from '../utils/apiErrorMessage.js' -import './FundingPage.css' -import './PageLayout.css' - -function FundingPageSkeleton() { - return ( - <> - - - - - - -
- - - - - - - - -
- - - - - ) -} - -/** Toggles between the two directions a single sortable column (Date) can run — the only sortable - * column `FundingHistoryTable` has, so this stays a small page-local `useState` rather than a - * dedicated hook/util (unlike `useLots`' filter+sort, which spans a whole status-filter toolbar). */ -function toggleDateSort(prev) { - if (prev.column !== 'date') return { column: 'date', direction: 'asc' } - return { column: 'date', direction: prev.direction === 'asc' ? 'desc' : 'asc' } -} - -export function FundingPage() { - const { principal } = useSession() - const { status, cashSummary, entries, error, refetch, refresh, isRefreshing } = useFunding() - const bankLinkState = useCurrentBankLink() - const [sort, setSort] = useState({ column: 'date', direction: 'desc' }) - const depositSectionRef = useRef(null) - const depositHeadingRef = useRef(null) - - const toggleSort = useCallback((column) => { - if (column !== 'date') return - setSort(toggleDateSort) - }, []) - - const sortedEntries = useMemo(() => { - const dir = sort.direction === 'asc' ? 1 : -1 - return [...entries].sort((a, b) => dir * a.effective_date.localeCompare(b.effective_date)) - }, [entries, sort]) - - const focusDepositForm = useCallback(() => { - const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches - depositSectionRef.current?.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' }) - depositHeadingRef.current?.focus() - }, []) - - if (status === 'idle' || status === 'loading') { - return ( -
-

Funding

- -
- ) - } - - if (status === 'error') { - return ( -
-

Funding

- -
- ) - } - - return ( -
-
-

Funding

-

Deposit into or withdraw from your investing account.

-
- - - - - -
-
-

- Deposit -

- - - -
-
-

Withdraw

- - - -
-
-
-

Funding history

- -
-
- ) -} diff --git a/frontend/src/pages/MoneyPage.css b/frontend/src/pages/MoneyPage.css new file mode 100644 index 0000000..c1ff180 --- /dev/null +++ b/frontend/src/pages/MoneyPage.css @@ -0,0 +1,73 @@ +.tu-money-page__subtitle { + margin: var(--space-1) 0 0; + font-size: var(--font-size-body); + color: var(--color-text-secondary); +} + +/* Same segmented-control vocabulary as DashboardPage.css's `.tu-hero-plate__range-toggle` -- + deposit/withdraw is a single form now (structure.md, Task 10), not two side-by-side cards. */ +.tu-money-page__mode-toggle { + display: inline-flex; + gap: var(--space-1); + align-self: flex-start; + border: 1px solid var(--color-hairline); + border-radius: var(--radius-sm); + padding: var(--space-1); + margin-bottom: var(--space-4); +} + +.tu-money-page__mode-button { + border: none; + background: none; + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-5); + font-size: var(--font-size-body); + font-weight: var(--font-weight-body-medium); + color: var(--color-text-secondary); + cursor: pointer; + min-height: 36px; +} + +.tu-money-page__mode-button:hover { + color: var(--color-text); +} + +.tu-money-page__mode-button--active { + background: var(--color-accent-subtle); + color: var(--color-accent-strong); +} + +.tu-money-page__mode-button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* The high-water mark is a single figure + a sentence; charge history is a table and takes the + slack -- matching the old FeesPage.css's column split exactly. */ +.tu-money-page__fee-columns { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--space-6); + align-items: start; +} + +@media (min-width: 768px) { + .tu-money-page__fee-columns { + grid-template-columns: minmax(240px, 1fr) minmax(0, 2fr); + } +} + +.tu-money-page__payment { + max-width: var(--reading-max-width); +} + +/* Both `moveMoneyHeadingRef` and `paymentHeadingRef` are reached only via a programmatic + .focus() call (tabIndex=-1 takes them out of the natural tab order) -- :focus, not + :focus-visible, so the ring reliably shows regardless of the browser's keyboard-vs-pointer + heuristic for a JS-driven focus() call. Matches the old FundingPage.css/FeesPage.css precedent. */ +.tu-money-page__move-money-section .tu-page__section-title:focus, +.tu-money-page__payment .tu-page__section-title:focus { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} diff --git a/frontend/src/pages/MoneyPage.jsx b/frontend/src/pages/MoneyPage.jsx new file mode 100644 index 0000000..cca3ba0 --- /dev/null +++ b/frontend/src/pages/MoneyPage.jsx @@ -0,0 +1,292 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { Card } from '../components/Card' +import { EmptyState } from '../components/EmptyState' +import { ErrorState } from '../components/ErrorState' +import { Skeleton } from '../components/Skeleton' +import { useSession } from '../contexts/SessionContext.jsx' +import { AccrualSummary } from '../features/fees/components/AccrualSummary.jsx' +import { ChargeHistory } from '../features/fees/components/ChargeHistory.jsx' +import { DunningBanner } from '../features/fees/components/DunningBanner.jsx' +import { HighWaterMarkCard } from '../features/fees/components/HighWaterMarkCard.jsx' +import { PaymentMethodForm } from '../features/fees/components/PaymentMethodForm.jsx' +import { useFees } from '../features/fees/hooks/useFees.js' +import { getBillingPeriod, hasNoFeeActivity } from '../features/fees/utils/feeSummary.js' +import { BankLinkCard } from '../features/funding/components/BankLinkCard.jsx' +import { CashSummary } from '../features/funding/components/CashSummary.jsx' +import { DepositForm } from '../features/funding/components/DepositForm.jsx' +import { FundingHistoryTable } from '../features/funding/components/FundingHistoryTable.jsx' +import { OutstandingBalanceBanner } from '../features/funding/components/OutstandingBalanceBanner.jsx' +import { WithdrawForm } from '../features/funding/components/WithdrawForm.jsx' +import { useCashSummary } from '../features/funding/hooks/useCashSummary.js' +import { useCurrentBankLink } from '../features/funding/hooks/useCurrentBankLink.js' +import { useFundingHistory } from '../features/funding/hooks/useFundingHistory.js' +import { getErrorMessage } from '../utils/apiErrorMessage.js' +import './MoneyPage.css' +import './PageLayout.css' + +const MODES = [ + { value: 'deposit', label: 'Deposit' }, + { value: 'withdraw', label: 'Withdraw' }, +] + +/** Toggles between the two directions `FundingHistoryTable`'s one sortable column (Date) can run + * — stays a small page-local `useState` rather than a dedicated hook. */ +function toggleDateSort(prev) { + if (prev.column !== 'date') return { column: 'date', direction: 'asc' } + return { column: 'date', direction: prev.direction === 'asc' ? 'desc' : 'asc' } +} + +function scrollToAndFocus(sectionRef, headingRef) { + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + sectionRef.current?.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' }) + headingRef.current?.focus() +} + +function CashCard({ cashSummary }) { + return ( + + {cashSummary.status === 'idle' || cashSummary.status === 'loading' ? ( + + ) : cashSummary.status === 'error' ? ( + + ) : ( + + )} + + ) +} + +/** Presentational only — `mode`/`onModeChange` are owned by `MoneyPage` so `focusDeposit` (from + * `OutstandingBalanceBanner`) can force it back to `'deposit'`. */ +function ModeToggle({ mode, onModeChange }) { + return ( +
+ {MODES.map((option) => ( + + ))} +
+ ) +} + +function FundingHistorySection({ history }) { + const [sort, setSort] = useState({ column: 'date', direction: 'desc' }) + + const toggleSort = useCallback((column) => { + if (column !== 'date') return + setSort(toggleDateSort) + }, []) + + const sortedEntries = useMemo(() => { + const dir = sort.direction === 'asc' ? 1 : -1 + return [...history.entries].sort((a, b) => dir * a.effective_date.localeCompare(b.effective_date)) + }, [history.entries, sort]) + + return ( +
+

Funding history

+ {history.status === 'idle' || history.status === 'loading' ? ( +
+ + +
+ ) : history.status === 'error' && history.entries.length === 0 ? ( + + ) : ( + + )} +
+ ) +} + +function FeesSkeleton() { + return ( + <> + + + + + + +
+ + + + + + + + +
+ + ) +} + +function FeesSection({ customerId, fees, billingPeriod, paymentSectionRef, paymentHeadingRef }) { + if (fees.status === 'idle' || fees.status === 'loading') { + return + } + + if (fees.status === 'error') { + return + } + + const isEmpty = hasNoFeeActivity(fees.accrual, fees.charges) + + return ( + <> + scrollToAndFocus(paymentSectionRef, paymentHeadingRef)} /> + {isEmpty ? ( + + ) : ( + <> + +
+
+

High-water mark

+ +
+
+

Charge history

+ +
+
+ + )} +
+

+ Payment method +

+ + + +
+ + ) +} + +export function MoneyPage() { + const { principal } = useSession() + const cashSummary = useCashSummary() + const history = useFundingHistory() + const bankLinkState = useCurrentBankLink() + const fees = useFees() + const [billingPeriod] = useState(getBillingPeriod) + const [mode, setMode] = useState('deposit') + const moveMoneySectionRef = useRef(null) + const moveMoneyHeadingRef = useRef(null) + const paymentSectionRef = useRef(null) + const paymentHeadingRef = useRef(null) + + const handleSubmitted = useCallback(() => { + cashSummary.refresh() + history.refetch() + }, [cashSummary, history]) + + // FR-43: pauses the deposit/withdraw flow on a stale bank Item by re-syncing bankLinkState -- + // once it reflects `requires_reauth`, both forms' own `disabledReasonFor` swap to the + // reconnect-required branch on the next render instead of letting a second submit fail the same + // way again. + const handleBankReauthRequired = useCallback(() => { + bankLinkState.refetch() + }, [bankLinkState]) + + const focusDeposit = useCallback(() => { + setMode('deposit') + scrollToAndFocus(moveMoneySectionRef, moveMoneyHeadingRef) + }, []) + + return ( +
+
+

Money

+

+ Deposit into or withdraw from your investing account, and manage the performance fee charged on gains. +

+
+ + {cashSummary.status === 'loaded' && ( + + )} + + + + + +
+

+ Move money +

+ + + {/* Both forms dereference `cashSummary` unconditionally once a bank link exists (caps, + withdrawable-balance checks) — never mount either before it has actually loaded. */} + {cashSummary.status === 'idle' || cashSummary.status === 'loading' ? ( + <> + + + + ) : cashSummary.status === 'error' ? ( + + ) : mode === 'deposit' ? ( + + ) : ( + + )} + +
+ + + +
+

Performance fee

+ +
+
+ ) +} From e945c03b03416638cfbd30f3d992e4c5d2609305 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Wed, 16 Sep 2026 12:47:06 -0700 Subject: [PATCH 12/14] feat(frontend): Account destination -- profile, both identity gates, statements Task 11 of the fullstack-rebuild plan. New `AccountPage.jsx` composes profile, both identity gates, bank link, and statements -- the same fold pattern Tasks 9/10 used for Invest/Money. Deletes `StatementsPage.jsx` (11 lines of pure chrome); `StatementList` inlines directly into `AccountPage`. `/account` sits inside `RequireOnboarded`; the separate `/onboarding` route stays outside it, unchanged from Task 7's trace -- `AccountPage` links out to `/onboarding` for an in-progress customer rather than embedding its steps. New `features/profile/` -- entirely net-new, `GET`/`PATCH /profile` had no frontend feature at all before this. `PATCH` is partial-update: only a field the user actually touched is included in the body, so "didn't touch" and "explicitly cleared" stay distinguishable; `null` values render as "Not set", never as a blank field that could read as still loading. `useIdentityStatus` now surfaces `kyc_status` and `account_approval_status` as two independent, separately-labeled indicators (previously only one was shown), plus `useLiveRefetch(['identity_status_changed'], refetch)`. Bonus fix riding along: `RequireOnboarded` (routes/guards.jsx) called `/identity/status` on every navigation despite its 30/min limit being shared with every other caller of that route; it now reads from `SessionContext`'s cached status, refreshed only by the live event above. `/onboarding`'s reachability re-traced end to end to confirm this doesn't change it. `OnboardingPage.jsx` restyled only; its "You're all set" CTA now points to `/invest` (was `/portfolio`, which no longer exists after Task 9). `useStatements` (Task 5) gets its first real page consumer; the restated-figure treatment (FR-26, as-published struck through alongside as-corrected) moves to a side-by-side presentation on the list itself. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p --- frontend/src/contexts/SessionContext.jsx | 16 ++- .../onboarding/hooks/useIdentityStatus.js | 71 +++++++--- .../src/features/profile/api/profileApi.js | 8 ++ .../profile/components/ProfileForm.css | 16 +++ .../profile/components/ProfileForm.jsx | 113 ++++++++++++++++ .../src/features/profile/hooks/useProfile.js | 36 +++++ .../profile/hooks/useUpdateProfile.js | 29 ++++ .../features/statements/api/statementsApi.js | 3 +- .../statements/components/StatementList.jsx | 96 +++++++------ frontend/src/pages/AccountPage.css | 17 +++ frontend/src/pages/AccountPage.jsx | 127 ++++++++++++++++++ frontend/src/pages/OnboardingPage.css | 24 +--- frontend/src/pages/OnboardingPage.jsx | 9 +- frontend/src/pages/StatementDetailPage.jsx | 7 +- frontend/src/pages/StatementsPage.jsx | 11 -- frontend/src/routes/guards.jsx | 6 + 16 files changed, 489 insertions(+), 100 deletions(-) create mode 100644 frontend/src/features/profile/api/profileApi.js create mode 100644 frontend/src/features/profile/components/ProfileForm.css create mode 100644 frontend/src/features/profile/components/ProfileForm.jsx create mode 100644 frontend/src/features/profile/hooks/useProfile.js create mode 100644 frontend/src/features/profile/hooks/useUpdateProfile.js create mode 100644 frontend/src/pages/AccountPage.css create mode 100644 frontend/src/pages/AccountPage.jsx delete mode 100644 frontend/src/pages/StatementsPage.jsx diff --git a/frontend/src/contexts/SessionContext.jsx b/frontend/src/contexts/SessionContext.jsx index d01b165..db275db 100644 --- a/frontend/src/contexts/SessionContext.jsx +++ b/frontend/src/contexts/SessionContext.jsx @@ -3,6 +3,13 @@ import { apiClient } from '../services/apiClient' const SessionContext = createContext(undefined) +/** + * `identityStatus`'s idle sentinel (structure.md §4: "Route guards read this rather than each + * page re-fetching /identity/status"). Exported so `useIdentityStatus` -- the only writer, via + * `setIdentityStatus` below -- can compare against the exact same shape without duplicating it. + */ +export const IDENTITY_STATUS_IDLE = { status: 'idle', kycStatus: null, accountApprovalStatus: null, error: null } + function principalFromAuthResponse(authResponse) { return { id: authResponse.id, email: authResponse.email, role: authResponse.role } } @@ -15,6 +22,7 @@ function principalFromAuthResponse(authResponse) { export function SessionProvider({ children }) { const [status, setStatus] = useState('loading') const [principal, setPrincipal] = useState(null) + const [identityStatus, setIdentityStatus] = useState(IDENTITY_STATUS_IDLE) const refresh = useCallback(async () => { setStatus('loading') @@ -41,6 +49,10 @@ export function SessionProvider({ children }) { apiClient.clearCsrfToken() setPrincipal(null) setStatus('anonymous') + // A stale approved/rejected verdict must never leak into the next session signed in on this + // tab (e.g. a shared device) -- the next customer's `useIdentityStatus` mount starts idle + // and fetches fresh, same as a first-ever login. + setIdentityStatus(IDENTITY_STATUS_IDLE) } }, []) @@ -52,7 +64,9 @@ export function SessionProvider({ children }) { }, []) return ( - + {children} ) diff --git a/frontend/src/features/onboarding/hooks/useIdentityStatus.js b/frontend/src/features/onboarding/hooks/useIdentityStatus.js index 93ef69a..7278115 100644 --- a/frontend/src/features/onboarding/hooks/useIdentityStatus.js +++ b/frontend/src/features/onboarding/hooks/useIdentityStatus.js @@ -1,44 +1,53 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { IDENTITY_STATUS_IDLE, useSession } from '../../../contexts/SessionContext.jsx' +import { useLiveRefetch } from '../../../hooks/useLiveRefetch.js' import { identityApi } from '../api/identityApi.js' -const IDLE = { status: 'idle', kycStatus: null, accountApprovalStatus: null, error: null } - // A still-pending refresh triggers a live outbound Stripe Identity poll (identity.py's -// get_identity_status), not a cheap read, and shares a single 30/min-per-IP bucket with -// RequireOnboarded's own background calls on every protected-route navigation -- a customer -// repeatedly clicking "Refresh status" could burn through that shared budget for the whole app. +// get_identity_status), not a cheap read, and shares a single 30/min-per-IP bucket across every +// customer session -- the manual "Refresh status" button is throttled client-side so repeatedly +// clicking it can't burn through that budget. const REFETCH_COOLDOWN_MS = 10_000 -// GET /identity/status/; pass null for non-customer principals. +/** + * `GET /identity/status/`, backed by the shared cache on `SessionContext` + * (structure.md §4) instead of component-local state. Every consumer -- `RequireOnboarded`, + * `OnboardingPage`, `AccountPage` -- reads and writes the *same* cache, so the status is fetched + * once per session (whichever consumer mounts first while it's still `'idle'`) rather than once + * per mount. A remount (e.g. `RequireOnboarded` unmounting/remounting as a customer bounces + * between `/onboarding` and a protected route) finds the cache already `'loaded'` and skips the + * network call entirely -- this is the fix for the previous per-navigation refetch. The cache is + * refreshed only by an explicit `refetch()` call or by one of the two live pushes below; a plain + * re-render never re-fetches. + */ export function useIdentityStatus(customerId) { - const [state, setState] = useState(IDLE) + const { identityStatus, setIdentityStatus } = useSession() const [cooldownRemainingMs, setCooldownRemainingMs] = useState(0) const lastFetchedAtRef = useRef(0) const fetchNow = useCallback(async () => { if (!customerId) { - setState(IDLE) + setIdentityStatus(IDENTITY_STATUS_IDLE) return } lastFetchedAtRef.current = Date.now() - setState((prev) => ({ ...prev, status: 'loading', error: null })) + setIdentityStatus((prev) => ({ ...prev, status: 'loading', error: null })) try { const data = await identityApi.getStatus(customerId) - setState({ + setIdentityStatus({ status: 'loaded', kycStatus: data.kyc_status, accountApprovalStatus: data.account_approval_status, error: null, }) } catch (error) { - setState({ status: 'error', kycStatus: null, accountApprovalStatus: null, error }) + setIdentityStatus({ status: 'error', kycStatus: null, accountApprovalStatus: null, error }) } - }, [customerId]) + }, [customerId, setIdentityStatus]) - // The initial mount fetch below always bypasses this -- there is nothing to cool down from yet. - // Every other caller (the "Refresh status" button, KycStep's onCompleted, ErrorState's onRetry) - // goes through this throttled path, since a spammed retry deserves the same protection as a - // spammed manual refresh. + // The manual path (the "Refresh status" button, KycStep's onCompleted, ErrorState's onRetry) -- + // cooldown-guarded, since a spammed click deserves the same protection the old per-navigation + // refetch needed. const refetch = useCallback(() => { if (Date.now() - lastFetchedAtRef.current < REFETCH_COOLDOWN_MS) { return @@ -46,24 +55,44 @@ export function useIdentityStatus(customerId) { fetchNow() }, [fetchNow]) + // The server-pushed verdict change (ADR 20) always wins over the cooldown: it only fires when a + // status genuinely changed, not on every navigation, so throttling it could leave a customer + // looking at a stale verdict until their next manual refresh or full page reload. The two gates + // publish independently (`EventPublisher.identity_status_changed`'s own docstring: "never merged + // into one event"), as `kyc_status_changed`/`account_approval_status_changed` -- there is no + // single `identity_status_changed` event_type on the wire. + useLiveRefetch(['kyc_status_changed', 'account_approval_status_changed'], fetchNow) + + // Populate-once: fetch only while the shared cache is still idle. Re-runs whenever `status` + // changes (fetchNow's own transitions included), but the condition is false by the time that + // happens, so this never loops -- it just means a fresh customerId (e.g. anonymous -> customer) + // gets exactly one fetch, and every later remount sharing the same cache is a no-op. useEffect(() => { - fetchNow() - }, [fetchNow]) + if (!customerId) { + if (identityStatus.status !== 'idle') { + setIdentityStatus(IDENTITY_STATUS_IDLE) + } + return + } + if (identityStatus.status === 'idle') { + fetchNow() + } + }, [customerId, identityStatus.status, fetchNow, setIdentityStatus]) // Ticks the remaining cooldown down to 0 so the UI can show "Try again in Ns" and re-enable // itself without polling Date.now() from the caller. useEffect(() => { - if (state.status !== 'loaded' && state.status !== 'error') return + if (identityStatus.status !== 'loaded' && identityStatus.status !== 'error') return const update = () => { setCooldownRemainingMs(Math.max(0, REFETCH_COOLDOWN_MS - (Date.now() - lastFetchedAtRef.current))) } update() const interval = setInterval(update, 250) return () => clearInterval(interval) - }, [state.status]) + }, [identityStatus.status]) return { - ...state, + ...identityStatus, refetch, canRefetch: cooldownRemainingMs === 0, cooldownRemainingSeconds: Math.ceil(cooldownRemainingMs / 1000), diff --git a/frontend/src/features/profile/api/profileApi.js b/frontend/src/features/profile/api/profileApi.js new file mode 100644 index 0000000..6ea655e --- /dev/null +++ b/frontend/src/features/profile/api/profileApi.js @@ -0,0 +1,8 @@ +import { apiClient } from '../../../services/apiClient.js' + +// ADR 27: self-scoped, no customer_id param on either route -- the session decides whose profile +// this is, server-side. +export const profileApi = { + get: () => apiClient.get('/profile'), + update: (fields) => apiClient.patch('/profile', fields), +} diff --git a/frontend/src/features/profile/components/ProfileForm.css b/frontend/src/features/profile/components/ProfileForm.css new file mode 100644 index 0000000..f4eea8f --- /dev/null +++ b/frontend/src/features/profile/components/ProfileForm.css @@ -0,0 +1,16 @@ +.tu-profile-form { + display: flex; + flex-direction: column; + gap: var(--space-4); + max-width: 480px; +} + +/* Not attachable to any single Input -- the backend's 422 (ADR 27) never names which field failed + validation, so this is FundingForm.css's own carved-out exception for a form-level message with + no associated field. */ +.tu-profile-form__error { + margin: 0; + font-size: var(--font-size-body); + line-height: var(--line-height-body); + color: var(--color-error); +} diff --git a/frontend/src/features/profile/components/ProfileForm.jsx b/frontend/src/features/profile/components/ProfileForm.jsx new file mode 100644 index 0000000..d5a22d9 --- /dev/null +++ b/frontend/src/features/profile/components/ProfileForm.jsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react' +import { Button } from '../../../components/Button' +import { Input } from '../../../components/Input' +import { useToast } from '../../../components/Toast' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { useUpdateProfile } from '../hooks/useUpdateProfile.js' +import './ProfileForm.css' + +const FIELDS = [ + { name: 'display_name', label: 'Display name', maxLength: 200 }, + { name: 'phone', label: 'Phone', maxLength: 32, hint: 'E.164 format, e.g. +14155552671' }, + { name: 'mailing_address', label: 'Mailing address', maxLength: 500 }, +] + +const PHONE_PATTERN = /^\+[1-9]\d{7,14}$/ + +function valuesFromProfile(profile) { + return { + display_name: profile?.display_name ?? '', + phone: profile?.phone ?? '', + mailing_address: profile?.mailing_address ?? '', + } +} + +/** + * `PATCH /profile` is a true partial update (ADR 27): a field the customer never touched must be + * left out of the request body entirely (the backend reads `model_fields_set`, so "omitted" and + * "present and null" are both real, distinct states), and a field they cleared to empty must go + * as an explicit `null`, never `""` -- the backend rejects a whitespace-only string as invalid + * input rather than treating it as a clear (`_validate_text_field`). `touched` is the only thing + * that decides what makes it into the PATCH body, not "did the value change from the loaded + * profile" -- re-typing the same value back out still counts as a deliberate, saved edit. + */ +function buildPatch(values, touched) { + const patch = {} + for (const name of Object.keys(touched)) { + if (!touched[name]) continue + const trimmed = values[name].trim() + patch[name] = trimmed === '' ? null : trimmed + } + return patch +} + +export function ProfileForm({ profile, onSaved }) { + const { status, error, save } = useUpdateProfile() + const { showToast } = useToast() + const [values, setValues] = useState(() => valuesFromProfile(profile)) + const [touched, setTouched] = useState({}) + const [phoneError, setPhoneError] = useState(null) + + // Resyncs whenever `profile` itself changes identity -- the initial load, and again after a + // successful save hands a fresh object back through `onSaved` -- discarding any now-stale + // touched/edited state rather than leaving it dangling against a profile that no longer matches. + useEffect(() => { + setValues(valuesFromProfile(profile)) + setTouched({}) + setPhoneError(null) + }, [profile]) + + const handleChange = (name) => (event) => { + setValues((prev) => ({ ...prev, [name]: event.target.value })) + setTouched((prev) => ({ ...prev, [name]: true })) + if (name === 'phone') setPhoneError(null) + } + + const hasChanges = Object.values(touched).some(Boolean) + const isSubmitting = status === 'submitting' + + const handleSubmit = async (event) => { + event.preventDefault() + if (!hasChanges) return + + const patch = buildPatch(values, touched) + if (patch.phone && !PHONE_PATTERN.test(patch.phone)) { + setPhoneError('Enter a valid phone number in E.164 format, e.g. +14155552671') + return + } + + try { + const updated = await save(patch) + showToast({ message: 'Profile updated.', tone: 'success' }) + onSaved?.(updated) + } catch { + // `status`/`error` below render the failure -- nothing else to do here. + } + } + + return ( +
+ {FIELDS.map((field) => ( + + ))} + {status === 'error' && ( +

+ {getErrorMessage(error)} +

+ )} + +
+ ) +} diff --git a/frontend/src/features/profile/hooks/useProfile.js b/frontend/src/features/profile/hooks/useProfile.js new file mode 100644 index 0000000..f3caff7 --- /dev/null +++ b/frontend/src/features/profile/hooks/useProfile.js @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from 'react' +import { profileApi } from '../api/profileApi.js' + +const IDLE = { status: 'idle', profile: null, error: null } + +/** + * `GET /profile`. No push event names a profile change (ADR 27 doesn't define one -- a customer + * editing their own display name/phone/address has no reason to notify any other session), so + * unlike the identity/order/cash hooks this has no `useLiveRefetch` subscription. + */ +export function useProfile() { + const [state, setState] = useState(IDLE) + + const refetch = useCallback(async () => { + setState((prev) => ({ ...prev, status: 'loading', error: null })) + try { + const profile = await profileApi.get() + setState({ status: 'loaded', profile, error: null }) + } catch (error) { + setState({ status: 'error', profile: null, error }) + } + }, []) + + // `PATCH /profile`'s own response is the fresh, authoritative profile -- applying it directly + // here after a successful `ProfileForm` save updates the cache in place, the same way + // `useCashSummary.refresh()` avoids a redundant round-trip after a deposit/withdrawal. + const applyUpdate = useCallback((profile) => { + setState({ status: 'loaded', profile, error: null }) + }, []) + + useEffect(() => { + refetch() + }, [refetch]) + + return { ...state, refetch, applyUpdate } +} diff --git a/frontend/src/features/profile/hooks/useUpdateProfile.js b/frontend/src/features/profile/hooks/useUpdateProfile.js new file mode 100644 index 0000000..f88d941 --- /dev/null +++ b/frontend/src/features/profile/hooks/useUpdateProfile.js @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react' +import { profileApi } from '../api/profileApi.js' + +/** + * `PATCH /profile`. `save`'s argument is already the exact partial-update body (ADR 27: a key + * present-with-null clears that field, a key omitted leaves it unchanged) -- `ProfileForm` owns + * deciding which keys belong in it, this hook just sends whatever it's given and surfaces the + * result. + */ +export function useUpdateProfile() { + const [status, setStatus] = useState('idle') // idle | submitting | submitted | error + const [error, setError] = useState(null) + + const save = useCallback(async (patch) => { + setStatus('submitting') + setError(null) + try { + const profile = await profileApi.update(patch) + setStatus('submitted') + return profile + } catch (err) { + setStatus('error') + setError(err) + throw err + } + }, []) + + return { status, error, save } +} diff --git a/frontend/src/features/statements/api/statementsApi.js b/frontend/src/features/statements/api/statementsApi.js index 542e630..91798cd 100644 --- a/frontend/src/features/statements/api/statementsApi.js +++ b/frontend/src/features/statements/api/statementsApi.js @@ -1,10 +1,11 @@ import { apiClient } from '../../../services/apiClient.js' +import { paginationQuery } from '../../../utils/paginationQuery.js' // `export` isn't wired here yet -- the real backend route (GET /statements//export) // exists and is tested (backend/app/controllers/api/statements.py), the frontend just hasn't // been pointed at it. See docs/superpowers/specs/2026-09-05-frontend-completion-design.md. export const statementsApi = { - list: () => apiClient.get('/statements'), + list: (pagination) => apiClient.get(`/statements${paginationQuery(pagination)}`), get: (periodStart, publishWatermark) => { const query = publishWatermark ? `?publish_watermark=${encodeURIComponent(publishWatermark)}` : '' return apiClient.get(`/statements/${periodStart}${query}`) diff --git a/frontend/src/features/statements/components/StatementList.jsx b/frontend/src/features/statements/components/StatementList.jsx index b867ec5..fb47605 100644 --- a/frontend/src/features/statements/components/StatementList.jsx +++ b/frontend/src/features/statements/components/StatementList.jsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' import { Badge } from '../../../components/Badge' +import { Button } from '../../../components/Button' import { EmptyState } from '../../../components/EmptyState' import { ErrorState } from '../../../components/ErrorState' import { Skeleton } from '../../../components/Skeleton' @@ -10,7 +11,7 @@ import { useStatements } from '../hooks/useStatements.js' import './StatementList.css' export function StatementList() { - const { status, periods, error, refetch } = useStatements() + const { status, statements, periods, nextCursor, error, refetch, loadMore } = useStatements() if (status === 'idle' || status === 'loading') { return ( @@ -21,7 +22,7 @@ export function StatementList() { ) } - if (status === 'error') { + if (status === 'error' && statements.length === 0) { return } @@ -30,44 +31,61 @@ export function StatementList() { } return ( - - - Period - Balance - Return - - - {periods.map((period) => ( - - -
- - {formatDate(period.periodStart)} – {formatDate(period.periodEnd)} - +
+
+ + Period + Balance + Return + + + {periods.map((period) => ( + + +
+ + {formatDate(period.periodStart)} – {formatDate(period.periodEnd)} + + {period.isRestated && ( + <> + Restated + + View original as-published statement + + + )} +
+
+ {period.isRestated && ( - <> - Restated - - View original as-published statement - - + {formatMoney(period.original.balance)} )} - - - - {period.isRestated && ( - {formatMoney(period.original.balance)} - )} - {formatMoney(period.current.balance)} - - - {formatPercent(period.current.twr)} - -
- ))} -
-
+ {formatMoney(period.current.balance)} + + + {formatPercent(period.current.twr)} + + + ))} + + + {status === 'error' ? ( + + ) : ( + nextCursor !== null && ( + + ) + )} +
) } diff --git a/frontend/src/pages/AccountPage.css b/frontend/src/pages/AccountPage.css new file mode 100644 index 0000000..cb7ac83 --- /dev/null +++ b/frontend/src/pages/AccountPage.css @@ -0,0 +1,17 @@ +.tu-account-page__subtitle { + margin: var(--space-1) 0 0; + font-size: var(--font-size-body); + color: var(--color-text-secondary); +} + +.tu-account-page__identity { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); +} + +.tu-account-page__onboarding-link { + font-size: var(--font-size-caption); + color: var(--color-accent); +} diff --git a/frontend/src/pages/AccountPage.jsx b/frontend/src/pages/AccountPage.jsx new file mode 100644 index 0000000..c4c68a2 --- /dev/null +++ b/frontend/src/pages/AccountPage.jsx @@ -0,0 +1,127 @@ +import { Link } from 'react-router-dom' +import { Card } from '../components/Card' +import { ErrorState } from '../components/ErrorState' +import { Skeleton } from '../components/Skeleton' +import { useSession } from '../contexts/SessionContext.jsx' +import { BankLinkCard } from '../features/funding/components/BankLinkCard.jsx' +import { useCurrentBankLink } from '../features/funding/hooks/useCurrentBankLink.js' +import { IdentityStatusBanner } from '../features/onboarding/components/IdentityStatusBanner.jsx' +import { useIdentityStatus } from '../features/onboarding/hooks/useIdentityStatus.js' +import { ProfileForm } from '../features/profile/components/ProfileForm.jsx' +import { useProfile } from '../features/profile/hooks/useProfile.js' +import { StatementList } from '../features/statements/components/StatementList.jsx' +import { getErrorMessage } from '../utils/apiErrorMessage.js' +import './AccountPage.css' +import './PageLayout.css' + +function ProfileSection() { + const profile = useProfile() + + if (profile.status === 'idle' || profile.status === 'loading') { + return ( + +
+ + + +
+
+ ) + } + + if (profile.status === 'error') { + return + } + + return ( + + + + ) +} + +/** + * Read-only here -- `AccountPage` is only reachable once `RequireOnboarded` has already let a + * customer through (KYC + account approval both `approved`, ADR 21), so both gates below are + * always shown approved. The link out to `/onboarding` is for the one remaining in-progress state + * this guard doesn't check: bank-link (see `BankAccountSection` below), and it deliberately does + * NOT embed `OnboardingSteps`/`KycStep` here -- those are the wizard's own interactive components, + * not a status display (structure.md §2.2: `/onboarding` "stays reachable post-approval too, + * read-only, as the account's identity/bank-link status view"). + */ +function IdentitySection() { + const { principal } = useSession() + const identity = useIdentityStatus(principal.id) + + if (identity.status === 'idle' || identity.status === 'loading') { + return + } + + if (identity.status === 'error') { + return + } + + return ( +
+ + + View onboarding details + +
+ ) +} + +/** Same `BankLinkCard` + `useCurrentBankLink()` pair Money uses (structure.md IA: bank-link lives + * on Account, but the link/reconnect action is equally at home wherever the status is shown). */ +function BankAccountSection() { + const { principal } = useSession() + const bankLink = useCurrentBankLink() + + return ( + + ) +} + +/** The Account destination (structure.md/Task 11): profile, both identity gates, bank link, and + * statements -- the fourth of the 5-destination nav's real screens (Task 7). `StatementsPage.jsx` + * (11 lines of pure chrome around `StatementList`) is gone; `StatementList` is inlined directly + * here instead of preserved as a purposeless wrapper. */ +export function AccountPage() { + return ( +
+
+

Account

+

+ Your profile, identity verification, linked bank account, and statements. +

+
+ +
+

Profile

+ +
+ +
+

Identity verification

+ +
+ +
+

Bank account

+ +
+ +
+

Statements

+ +
+
+ ) +} diff --git a/frontend/src/pages/OnboardingPage.css b/frontend/src/pages/OnboardingPage.css index 83e548d..709ba28 100644 --- a/frontend/src/pages/OnboardingPage.css +++ b/frontend/src/pages/OnboardingPage.css @@ -1,25 +1,5 @@ -.tu-onboarding-page { - max-width: var(--content-max-width); - margin: 0 auto; - padding: var(--space-8) var(--space-6); - display: flex; - flex-direction: column; - gap: var(--space-6); -} - -.tu-onboarding-page__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); -} - -.tu-onboarding-page__title { - font-size: var(--font-size-heading-lg); - line-height: var(--line-height-heading-lg); - font-weight: var(--font-weight-heading-lg); - color: var(--color-text); -} +/* `.tu-page`/`.tu-page__header`/`.tu-page__title` (PageLayout.css) now own layout/typography here + -- this file only carries what's unique to the onboarding-complete banner. */ .tu-onboarding-page__complete { display: flex; diff --git a/frontend/src/pages/OnboardingPage.jsx b/frontend/src/pages/OnboardingPage.jsx index e29cebd..ca80cf2 100644 --- a/frontend/src/pages/OnboardingPage.jsx +++ b/frontend/src/pages/OnboardingPage.jsx @@ -10,6 +10,7 @@ import { OnboardingSteps, StepRow } from '../features/onboarding/components/Onbo import { useIdentityStatus } from '../features/onboarding/hooks/useIdentityStatus.js' import { useCurrentBankLink } from '../features/funding/hooks/useCurrentBankLink.js' import './OnboardingPage.css' +import './PageLayout.css' function BankLinkSection({ customerId, bankLink, showTitle }) { if (bankLink.status === 'idle' || bankLink.status === 'loading') { @@ -42,9 +43,9 @@ export function OnboardingPage() { const bankStepStatus = isBankLinked ? 'completed' : isKycApproved ? 'current' : 'locked' return ( -
-
-

Identity and funding status

+
+
+

Identity and funding status

{identity.status === 'loaded' && (
diff --git a/frontend/src/pages/StatementDetailPage.jsx b/frontend/src/pages/StatementDetailPage.jsx index ddf6dc3..6ba44ee 100644 --- a/frontend/src/pages/StatementDetailPage.jsx +++ b/frontend/src/pages/StatementDetailPage.jsx @@ -1,4 +1,5 @@ -import { useParams } from 'react-router-dom' +import { Link, useParams } from 'react-router-dom' +import { Icon } from '../components/Icon' import { StatementDetail } from '../features/statements/components/StatementDetail.jsx' import './PageLayout.css' @@ -6,6 +7,10 @@ export function StatementDetailPage() { const { periodStart } = useParams() return (
+ + + Back to Account +

Statement detail

diff --git a/frontend/src/pages/StatementsPage.jsx b/frontend/src/pages/StatementsPage.jsx deleted file mode 100644 index 9453356..0000000 --- a/frontend/src/pages/StatementsPage.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import { StatementList } from '../features/statements/components/StatementList.jsx' -import './PageLayout.css' - -export function StatementsPage() { - return ( -
-

Statements

- -
- ) -} diff --git a/frontend/src/routes/guards.jsx b/frontend/src/routes/guards.jsx index 0bac6a0..dc9e2a0 100644 --- a/frontend/src/routes/guards.jsx +++ b/frontend/src/routes/guards.jsx @@ -41,6 +41,12 @@ export function RequireRole({ roles, children }) { /** * Gates customer routes (§2.3) on KYC + account approval (FR-3, ADR 21); staff pass through. * Fails closed: a load error is treated as not-yet-approved (regulatory gate, not a UX nicety). + * + * `useIdentityStatus` reads/writes the shared cache on `SessionContext` (structure.md §4), not + * component-local state -- so this guard remounting as a customer bounces between `/onboarding` + * and a protected route (`/onboarding` is a sibling route outside this wrapper, see App.jsx) does + * NOT re-fetch `/identity/status` every time; only the first mount per session (cache still idle) + * or a live `identity_status_changed` push does. */ export function RequireOnboarded({ children }) { const { status, principal } = useSession() From 20b9570f6e0c0881abe4f3c58b871be2cbba1148 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Wed, 16 Sep 2026 12:47:53 -0700 Subject: [PATCH 13/14] feat(admin): rewire customer directory + KYC overrides from mockClient to real endpoints Task 12 of the fullstack-rebuild plan. `adminCustomersApi.js` was entirely backed by `mockClient` despite the real endpoints (`GET /admin/customers`, `GET /admin/customers/`, `GET /admin/customers//fees`, `POST /admin/kyc-overrides/`) already existing and working. Every method now calls `apiClient` against the real paths. `GET /admin/customers` is already server-side paginated -- given Task 5's "Load more" hook shape rather than a bespoke pagination UI. `useBreaks` gains `useLiveRefetch(['break_opened'], refetch)`, subscribing via `eventStream.js`'s admin-stream variant. `mockClient.js` itself is untouched and not deleted -- it stays as documented pure infra even with its last live consumer gone, per the approved plan. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p --- .../features/admin-breaks/hooks/useBreaks.js | 3 + .../admin-customers/api/adminCustomersApi.js | 72 ++++--------------- .../components/CustomerDirectory.jsx | 30 ++++++-- .../components/CustomerSummary.jsx | 1 - .../components/KycOverrideForm.jsx | 33 ++++++--- .../hooks/useCustomerDetail.js | 4 -- .../hooks/useCustomerSearch.js | 37 ++++++++-- .../admin-customers/hooks/useKycOverride.js | 6 +- .../src/pages/admin/CustomerDetailPage.jsx | 21 +++--- .../src/pages/admin/CustomerDirectoryPage.jsx | 5 +- 10 files changed, 106 insertions(+), 106 deletions(-) diff --git a/frontend/src/features/admin-breaks/hooks/useBreaks.js b/frontend/src/features/admin-breaks/hooks/useBreaks.js index 63119ae..9e49b9b 100644 --- a/frontend/src/features/admin-breaks/hooks/useBreaks.js +++ b/frontend/src/features/admin-breaks/hooks/useBreaks.js @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useLiveRefetch } from '../../../hooks/useLiveRefetch.js' import { adminBreaksApi } from '../api/adminBreaksApi.js' const IDLE = { status: 'idle', breaks: [], error: null } @@ -17,6 +18,8 @@ export function useBreaks() { } }, []) + useLiveRefetch(['break_opened'], refetch) + useEffect(() => { refetch() }, [refetch]) diff --git a/frontend/src/features/admin-customers/api/adminCustomersApi.js b/frontend/src/features/admin-customers/api/adminCustomersApi.js index 26b50cf..1597ea9 100644 --- a/frontend/src/features/admin-customers/api/adminCustomersApi.js +++ b/frontend/src/features/admin-customers/api/adminCustomersApi.js @@ -1,64 +1,16 @@ -// MOCK -- the real backend routes (GET /admin/customers*, POST /admin/kyc-overrides/) exist -// and are tested (backend/app/controllers/admin/), the frontend just hasn't been wired to them -// yet. See docs/superpowers/specs/2026-09-05-frontend-completion-design.md. -import { mockClient } from '../../../services/mockClient.js' - -const NAMESPACE = 'admin-customers' - -function seed() { - return { - customers: [ - { - id: 'cust-1', - email: 'amelia.chen@example.com', - kyc_status: 'approved', - account_approval_status: 'approved', - has_open_break: false, - }, - { - id: 'cust-2', - email: 'marcus.webb@example.com', - kyc_status: 'pending', - account_approval_status: 'pending', - has_open_break: false, - }, - { - id: 'cust-3', - email: 'priya.natarajan@example.com', - kyc_status: 'rejected', - account_approval_status: 'rejected', - has_open_break: true, - }, - { - id: 'cust-4', - email: 'daniel.osei@example.com', - kyc_status: 'approved', - account_approval_status: 'approved', - has_open_break: true, - }, - ], - } -} +// Admin customer directory/detail/KYC-override routes (S8 §4 rows 1, 2, 5). Adviser/admin-only -- +// see backend/app/controllers/admin/{customers,kyc_overrides}.py. +import { apiClient } from '../../../services/apiClient.js' export const adminCustomersApi = { - search: (query) => { - const store = mockClient.getStore(NAMESPACE, seed) - const results = query ? store.customers.filter((c) => c.email.toLowerCase().includes(query.toLowerCase())) : [] - return mockClient.request(results) - }, - getDetail: (customerId) => { - const store = mockClient.getStore(NAMESPACE, seed) - return mockClient.request(store.customers.find((c) => c.id === customerId) || null) - }, - submitKycOverride: (customerId, decision) => { - const store = mockClient.getStore(NAMESPACE, seed) - const customer = store.customers.find((c) => c.id === customerId) - if (customer) { - customer.kyc_status = decision - if (decision === 'approved') { - customer.account_approval_status = 'approved' - } - } - return mockClient.request(customer) + search: (query, { after, limit } = {}) => { + const params = new URLSearchParams({ query }) + if (after) params.set('cursor', after) + if (limit) params.set('limit', String(limit)) + return apiClient.get(`/admin/customers?${params.toString()}`) }, + getDetail: (customerId) => apiClient.get(`/admin/customers/${customerId}`), + // Unconditionally reopens a locked-`rejected` KYC status to `pending` (S8 §4 row 5) -- there is + // no approve/reject decision to send, only an audit `reason`. + submitKycOverride: (customerId, reason) => apiClient.post(`/admin/kyc-overrides/${customerId}`, { reason }), } diff --git a/frontend/src/features/admin-customers/components/CustomerDirectory.jsx b/frontend/src/features/admin-customers/components/CustomerDirectory.jsx index 6bae6a6..f3b60f8 100644 --- a/frontend/src/features/admin-customers/components/CustomerDirectory.jsx +++ b/frontend/src/features/admin-customers/components/CustomerDirectory.jsx @@ -1,4 +1,5 @@ import { useNavigate } from 'react-router-dom' +import { Button } from '../../../components/Button' import { EmptyState } from '../../../components/EmptyState' import { ErrorState } from '../../../components/ErrorState' import { Skeleton } from '../../../components/Skeleton' @@ -10,7 +11,7 @@ import { CustomerSummary } from './CustomerSummary.jsx' import './CustomerDirectory.css' export function CustomerDirectory() { - const { status, query, results, error, search } = useCustomerSearch() + const { status, query, customers, nextCursor, error, search, loadMore } = useCustomerSearch() const navigate = useNavigate() return ( @@ -18,23 +19,40 @@ export function CustomerDirectory() { {status === 'idle' && } {status === 'searching' && } - {status === 'error' && search(query)} />} - {status === 'loaded' && results.length === 0 && } - {status === 'loaded' && results.length > 0 && ( + {status === 'error' && customers.length === 0 && search(query)} />} + {(status === 'loaded' || status === 'loading-more') && customers.length === 0 && ( + + )} + {customers.length > 0 && ( Email KYC status Account approval -   - {results.map((customer) => ( + {customers.map((customer) => ( navigate(`/admin/customers/${customer.id}`)} /> ))}
)} + {customers.length > 0 && + (status === 'error' ? ( + + ) : ( + nextCursor !== null && ( + + ) + ))}
) } diff --git a/frontend/src/features/admin-customers/components/CustomerSummary.jsx b/frontend/src/features/admin-customers/components/CustomerSummary.jsx index f92a8ab..ec2560d 100644 --- a/frontend/src/features/admin-customers/components/CustomerSummary.jsx +++ b/frontend/src/features/admin-customers/components/CustomerSummary.jsx @@ -15,7 +15,6 @@ export function CustomerSummary({ customer, onClick }) { {customer.account_approval_status} - {customer.has_open_break && Open break} ) } diff --git a/frontend/src/features/admin-customers/components/KycOverrideForm.jsx b/frontend/src/features/admin-customers/components/KycOverrideForm.jsx index c17ca93..f0bdded 100644 --- a/frontend/src/features/admin-customers/components/KycOverrideForm.jsx +++ b/frontend/src/features/admin-customers/components/KycOverrideForm.jsx @@ -1,32 +1,42 @@ import { useState } from 'react' import { Button } from '../../../components/Button' -import { Select } from '../../../components/Select' +import { Input } from '../../../components/Input' import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { useKycOverride } from '../hooks/useKycOverride.js' import './KycOverrideForm.css' -/** Design system §8.6 privileged-action pattern. */ +/** Design system §8.6 privileged-action pattern. The backend action (`POST + * /admin/kyc-overrides/`) unconditionally reopens a locked `rejected` KYC status to `pending` + * with an audit `reason` -- there is no approve/reject decision to make here, so this only renders + * for a customer currently locked out (S8 §4 row 5, S8 §6 edge case 3). */ export function KycOverrideForm({ customer, onOverridden }) { const { status, error, submit } = useKycOverride() const [confirming, setConfirming] = useState(false) - const [decision, setDecision] = useState('approved') + const [reason, setReason] = useState('') const isSubmitting = status === 'submitting' + if (customer.kyc_status !== 'rejected') { + return null + } + return (
{!confirming ? ( ) : (
-

- Set {customer.email}'s KYC status to {decision}? + Reopen {customer.email}'s KYC status to pending so they can resubmit?

+ setReason(event.target.value)} + placeholder="Why is this override being made?" + /> {status === 'error' && (

{getErrorMessage(error)} @@ -35,15 +45,16 @@ export function KycOverrideForm({ customer, onOverridden }) {

diff --git a/frontend/src/features/admin-customers/hooks/useCustomerDetail.js b/frontend/src/features/admin-customers/hooks/useCustomerDetail.js index 45d6183..0b43b24 100644 --- a/frontend/src/features/admin-customers/hooks/useCustomerDetail.js +++ b/frontend/src/features/admin-customers/hooks/useCustomerDetail.js @@ -10,10 +10,6 @@ export function useCustomerDetail(customerId) { setState((prev) => ({ ...prev, status: 'loading', error: null })) try { const customer = await adminCustomersApi.getDetail(customerId) - if (customer === null) { - setState({ status: 'error', customer: null, error: { code: 'not_found' } }) - return - } setState({ status: 'loaded', customer, error: null }) } catch (error) { setState({ status: 'error', customer: null, error }) diff --git a/frontend/src/features/admin-customers/hooks/useCustomerSearch.js b/frontend/src/features/admin-customers/hooks/useCustomerSearch.js index 12c8ddd..5c974b1 100644 --- a/frontend/src/features/admin-customers/hooks/useCustomerSearch.js +++ b/frontend/src/features/admin-customers/hooks/useCustomerSearch.js @@ -1,21 +1,46 @@ import { useCallback, useState } from 'react' import { adminCustomersApi } from '../api/adminCustomersApi.js' -const IDLE = { status: 'idle', query: '', results: [], error: null } +const IDLE = { status: 'idle', query: '', customers: [], nextCursor: null, error: null } +// status: 'idle' | 'searching' | 'loaded' | 'loading-more' | 'error' -/** idle = no query submitted yet, distinct from an empty result. */ +/** idle = no query submitted yet, distinct from an empty result. The backend rejects a blank + * `query` (422) -- rather than round-trip for that, a blank/whitespace-only query resolves to an + * empty result locally, same as the old mock's behaviour. */ export function useCustomerSearch() { const [state, setState] = useState(IDLE) const search = useCallback(async (query) => { + const trimmed = query.trim() + if (!trimmed) { + setState({ status: 'loaded', query, customers: [], nextCursor: null, error: null }) + return + } setState((prev) => ({ ...prev, status: 'searching', query, error: null })) try { - const results = await adminCustomersApi.search(query) - setState({ status: 'loaded', query, results, error: null }) + const data = await adminCustomersApi.search(trimmed) + setState({ status: 'loaded', query, customers: data.customers, nextCursor: data.next_cursor, error: null }) } catch (error) { - setState({ status: 'error', query, results: [], error }) + setState({ status: 'error', query, customers: [], nextCursor: null, error }) } }, []) - return { ...state, search } + const loadMore = useCallback(async () => { + if (state.nextCursor === null || state.status === 'loading-more') return + setState((prev) => ({ ...prev, status: 'loading-more' })) + try { + const data = await adminCustomersApi.search(state.query.trim(), { after: state.nextCursor }) + setState((prev) => ({ + status: 'loaded', + query: prev.query, + customers: [...prev.customers, ...data.customers], + nextCursor: data.next_cursor, + error: null, + })) + } catch (error) { + setState((prev) => ({ ...prev, status: 'error', error })) + } + }, [state.query, state.nextCursor, state.status]) + + return { ...state, search, loadMore } } diff --git a/frontend/src/features/admin-customers/hooks/useKycOverride.js b/frontend/src/features/admin-customers/hooks/useKycOverride.js index 6c269e1..657aeb7 100644 --- a/frontend/src/features/admin-customers/hooks/useKycOverride.js +++ b/frontend/src/features/admin-customers/hooks/useKycOverride.js @@ -5,13 +5,13 @@ export function useKycOverride() { const [status, setStatus] = useState('idle') const [error, setError] = useState(null) - const submit = useCallback(async (customerId, decision) => { + const submit = useCallback(async (customerId, reason) => { setStatus('submitting') setError(null) try { - const customer = await adminCustomersApi.submitKycOverride(customerId, decision) + const status = await adminCustomersApi.submitKycOverride(customerId, reason) setStatus('submitted') - return customer + return status } catch (err) { setError(err) setStatus('error') diff --git a/frontend/src/pages/admin/CustomerDetailPage.jsx b/frontend/src/pages/admin/CustomerDetailPage.jsx index d698f1c..0efed8f 100644 --- a/frontend/src/pages/admin/CustomerDetailPage.jsx +++ b/frontend/src/pages/admin/CustomerDetailPage.jsx @@ -2,7 +2,6 @@ import { useParams } from 'react-router-dom' import { Card } from '../../components/Card' import { DetailFields } from '../../components/DetailFields' import { ErrorState } from '../../components/ErrorState' -import { SimulatedBadge } from '../../components/SimulatedBadge' import { Skeleton } from '../../components/Skeleton' import { CustomerFeesPanel } from '../../features/admin-customers/components/CustomerFeesPanel.jsx' import { KycOverrideForm } from '../../features/admin-customers/components/KycOverrideForm.jsx' @@ -41,14 +40,14 @@ export function CustomerDetailPage() { ) } + const hasOpenBreak = customer.open_reconciliation_breaks.length > 0 + return (

Customer detail

- {customer.has_open_break && } + {hasOpenBreak && } -

- Profile -

+

Profile

- -

- KYC override -

- -
+ {customer.kyc_status === 'rejected' && ( + +

KYC override

+ +
+ )}

Fees

diff --git a/frontend/src/pages/admin/CustomerDirectoryPage.jsx b/frontend/src/pages/admin/CustomerDirectoryPage.jsx index 76d2eec..39278ad 100644 --- a/frontend/src/pages/admin/CustomerDirectoryPage.jsx +++ b/frontend/src/pages/admin/CustomerDirectoryPage.jsx @@ -1,13 +1,10 @@ -import { SimulatedBadge } from '../../components/SimulatedBadge' import { CustomerDirectory } from '../../features/admin-customers/components/CustomerDirectory.jsx' import '../PageLayout.css' export function CustomerDirectoryPage() { return (
-

- Customers -

+

Customers

) From d4906633f8ce61be93159f300f65fb6c60b13e13 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Wed, 16 Sep 2026 13:45:59 -0700 Subject: [PATCH 14/14] fix(backend): upgrade base-image OS packages at build time The runtime image's apt step installed libpq5 but never upgraded the base image's own packages, so a fresh build silently ships whatever CVEs were open in python:3.12-slim's last-published layer regardless of anything this repo controls. Confirmed via CI: PR #33's Docker/Trivy scan failed on 3 CRITICAL + 9 HIGH CVEs (gzip, perl-base, libsqlite3-0, libpcre2-8-0) -- and confirmed unrelated to this branch by building main's own unmodified Dockerfile fresh and getting the byte-identical CVE list (main's last green CI run was a week before this one; the fix pulls patched Debian packages published since). `apt-get upgrade -y` after the existing install resolves all 12 -- verified locally with `trivy image --severity HIGH,CRITICAL --ignore-unfixed`: 0 findings across the OS layer and every Python dependency. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p --- backend/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/Dockerfile b/backend/Dockerfile index 4a83226..3427b37 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,8 +28,13 @@ RUN uv sync --frozen --no-dev FROM python:3.12-slim AS runtime # libpq is needed at runtime by psycopg[binary]'s C extension load path on slim images. +# `apt-get upgrade` pulls the base image's own OS packages up to their latest patched version at +# build time -- without it, a build can silently ship whatever CVEs were open in the base image's +# last-published layer, unrelated to anything this Dockerfile installs itself (verified 2026-09-16: +# 3 CRITICAL + 9 HIGH CVEs in gzip/perl-base/libsqlite3-0/libpcre2-8-0, all fixed by this one line). RUN apt-get update \ && apt-get install -y --no-install-recommends libpq5 \ + && apt-get upgrade -y \ && rm -rf /var/lib/apt/lists/* RUN groupadd --system trueup && useradd --system --gid trueup --create-home trueup