Skip to content

Redesign customer pages (Fees, Lots, Funding) and extend backend for Portfolio, Orders, Profile - #25

Merged
Badri-Rajendran merged 8 commits into
mainfrom
feature_customer_pages_redesign_and_backend_extensions
Sep 7, 2026
Merged

Badri-Rajendran merged 8 commits into
mainfrom
feature_customer_pages_redesign_and_backend_extensions

Conversation

@Badri-Rajendran

Copy link
Copy Markdown
Owner

Summary

Bundles the customer-facing page redesign programme run this session, front-to-back:

  • Fees, Tax Lots, Funding: full UI redesign + backend extension, each independently verified
    in a live browser against the real stack (real Stripe/Plaid Sandbox).
  • Portfolio, Orders, Profile: backend-only in this PR (new endpoints, each behind its own
    ADR) — frontend redesign for these three is tracked as follow-on work.

Problem

Several customer pages had a real, working backend but a thin or placeholder frontend (no
loading/empty/error states, stale data after a submit, accessibility gaps), and several pages were
missing backend capability entirely that the product needs: real per-security holdings (customers
could only see a model's target weights, never what they actually held), a performance
time-series, order cancellation, and any stored customer profile data beyond an email address.

What changed

Fees — real Stripe Elements card entry, HighWaterMarkCard/BillingPeriodProgress making fee
accrual mechanics legible, an honest DunningBanner for a past-due charge.

Tax Lots — GET /lots extended with unrealized gain/loss, wash-sale disclosure, and
short/long-term holding-period classification; new LotsSummaryService; fixed a shared
Table.css bug forcing the whole page to scroll horizontally on a wide table.

Funding — GET /funding/cash-summary extended with outstanding_receivable,
deposit_cap_per_transaction, deposit_cap_per_day, deposited_today; new
GET /funding/history with real per-deposit settlement status. Frontend fixes a double
GET /cash-summary fetch and a form-error accessibility bug (aria-invalid never fired).

Portfolio (ADR 26) — GET /api/v1/portfolios/holdings (real per-security holdings, reusing
DriftEvaluationService) and GET /api/v1/portfolios/performance (a live, as-of-now dated-value
series from sub_period_return, range as an allowlisted enum — explicitly never merged with
GET /statements's as-published series).

Orders (ADR 25) — POST /api/v1/orders/<id>/cancel: a broker cancel request, never a local
status mutation. The order only ever transitions to canceled via the existing trade-updates
websocket (ADR 22); a fill racing an accepted cancel request still lands the order on filled.
Also: per-fill quantity/price on order events, the tax lots each fill opened/consumed, and a
specific customer-safe reason code replacing a generic 403 on ineligible submission.

Profile (ADR 27) — the platform's first stored customer PII (display name, phone, mailing
address), self-scoped GET/PATCH /api/v1/profile, @audited on write with raw values kept out
of the audit payload.

Validation evidence

  • Backend: uv run ruff check app tests (76 pre-existing baseline errors, confirmed identical on
    unmodified origin/main via a worktree diff — zero new), uv run mypy --strict app (2
    pre-existing baseline errors, unrelated file), uv run lint-imports (5 kept, 0 broken).
  • Backend tests: full new-endpoint coverage — Funding (28 API + 12 integration), Portfolio (24),
    Orders (48, including a fill-races-cancel regression test), Profile (25, including a self-scoping
    isolation test and an audit-payload redaction test) — all passing. One real test bug found and
    fixed during review (an FK-violating manual customer delete in test_orders.py, replaced with a
    unit-level test of the actual unreachable-via-HTTP code path).
  • Frontend: npm run lint, npm run build both clean. Fees/Lots/Funding each independently
    verified in a live Playwright pass against the real running stack (real Postgres, real
    Plaid/Stripe Sandbox) — golden path, empty state, error state, 375px, dark theme.
  • Alembic migration (Profile's new columns) applies and downgrades cleanly against a real Postgres
    instance.

Security / DB / API impact

  • DB: one new migration — three nullable String columns on customer (Profile). No other
    schema changes; every other new endpoint reads existing tables.
  • API: five new routes (GET /funding/history, GET /portfolios/holdings,
    GET /portfolios/performance, POST /orders/<id>/cancel, GET/PATCH /profile), all
    session-scoped and rate-limited; Orders/BrokerPort gains cancel_order, implemented for both
    the real Alpaca adapter and the test double.
  • Security: Profile is the first customer PII stored in this database — see ADR 27 for the
    storage/access/redaction decisions. Order cancellation never locally mutates authoritative state
    (ADR 25) — the broker-confirmed websocket event remains the only source of truth. Order
    submission's generic 403 is replaced with an allowlisted, customer-safe reason code — the raw
    internal reason and any KYC-provider detail never reach the wire.

Follow-on (not in this PR)

  • Frontend redesign for Portfolio, Orders, Profile (backend-only here).
  • Dashboard (depends on all three above).

The dev server default (5000) collides with macOS AirPlay Receiver; every
feature this session runs the backend on 5001 instead.
…st disclosure

Real Stripe Elements card entry replaces the placeholder payment form. New
HighWaterMarkCard and BillingPeriodProgress make the accrual mechanics
legible instead of a bare number. DunningBanner states the past-due amount
and next step plainly rather than a generic failure message. No
progress-toward-fee estimate was added — the accrual period is disclosed,
never projected ahead of the actual charge. ChargeHistory/AccrualSummary
lost their own loading/error branches in favor of page-level states.
…disclosure

Extend GET /lots with unrealized gain/loss, wash-sale-adjustment
disclosure, and short/long-term holding-period classification (open lots
only). New LotsSummaryService backs the page's totals; HoldingPeriodBadge,
ConsumptionHistory (per-consumption sale detail), and LotFilterToolbar
replace the previous flat, undifferentiated table.

Fixes a shared Table.css bug: a wide table's content-based width forced
the whole page to scroll horizontally instead of just the table.
…e page

Extend GET /funding/cash-summary with outstanding_receivable,
deposit_cap_per_transaction, deposit_cap_per_day, and deposited_today
(FR-6's bounced-deposit disclosure and real deposit-cap visibility were
previously invisible to the customer). Add GET /funding/history returning
per-deposit/withdrawal rows with real settlement status
(pending/confirmed/failed) -- a bounced deposit is one row with
failure_reason: "ach_return", never a separate correction-entry row.
New shared deposited_on() helper (services/identity/_shared.py) so the
cap-enforcement path and this new disclosure path can never diverge on
"deposited today".

Frontend: OutstandingBalanceBanner, DepositLimits, BankLinkCard,
FundingHistoryTable, SettlementStatusPill are new; CashSummary becomes
presentational, fixing a double GET /cash-summary fetch per page load;
form errors now reach Input's aria-invalid state; new useFunding hook
replaces useCashPolicy with a refresh() path that updates balance/history
in place after a submit instead of blanking back to skeletons.
GET /api/v1/portfolios/holdings: real per-security holdings -- units,
market value, current vs. target weight, drift -- reusing
DriftEvaluationService rather than recomputing weights. A security held
but dropped from the model still appears with target_weight_pct: 0; a
partial valuation evaluation is reported as completeness: "partial", never
silently returned as an empty holdings list. Previously this data existed
only behind the adviser/admin-only GET /admin/rebalance/<customer_id>.

GET /api/v1/portfolios/performance: a live, as-of-now dated-value series
sourced from sub_period_return (the only table that stores dated portfolio
values), range as a strict allowlist (1m/3m/6m/1y/all), never a free-form
date pair. Deliberately distinct from GET /statements' as-published
series -- see ADR 26 for why the two are never merged or presented as
interchangeable.
POST /api/v1/orders/<id>/cancel requests broker-side cancellation without
locally mutating order status -- the authoritative canceled transition
still arrives only via the existing trade-updates websocket (ADR 22),
folded by OrderProjectionService. A fill racing an accepted cancel request
still lands the order on filled, never canceled, with no special-casing
needed since request_cancel never wrote to order.status in the first
place. Adds BrokerPort.cancel_order, implemented for both AlpacaBrokerAdapter
and FakeBrokerAdapter. Cancellable only from submitted/accepted/
partially_filled; idempotent while cancellable, 409 once terminal.
@Audited("orders.cancel_requested").

Also: order detail now includes per-fill quantity/price on each event, and
the tax lots a buy opened / a sell consumed (already FK-enforced, not
previously on the wire). A generic 403 on order-submission ineligibility
is replaced with a specific, customer-safe reason code via an allowlist --
the raw internal reason, and any KYC-provider-specific detail, never
reaches the wire.

Fixed a test bug found during review: a manual mid-test customer delete
violated CustomerCashLock's FK (no ON DELETE CASCADE); replaced with a
direct unit-level test of the code-mapping function, since a real HTTP
request can never reach that reason -- an authenticated session already
implies a live Customer row.
The platform's first stored human-identity PII: display_name, phone,
mailing_address as nullable columns on customer (nullable because every
pre-existing row predates them). Real identity data continues to live
only in Stripe Identity's hosted KYC flow (ADR 9) -- this adds no SSN,
DOB, or government ID storage.

GET/PATCH /api/v1/profile are self-scoped only: the customer is resolved
entirely from the session, with no customer_id parameter of any kind to
check or get wrong. PATCH is @Audited("identity.profile.update") with the
raw values kept out of the audit payload (a SHA-256 payload hash only).
Validation errors reference field names only, never the submitted value.
phone is validated E.164; display_name/mailing_address get length bounds
and reject empty/whitespace-only input.
Summarizes the Fees, Tax Lots, Funding, Portfolio, Orders, and Profile
work in CHANGELOG.md; indexes ADRs 25-27 in docs/decisions/README.md.
Copilot AI lite review requested due to automatic review settings September 7, 2026 05:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Badri-Rajendran
Badri-Rajendran merged commit 063707e into main Sep 7, 2026
5 of 6 checks passed
Badri-Rajendran added a commit that referenced this pull request Sep 7, 2026
…on_scope

fix(ci): CI has been red since PR #25 -- test_orders.py's session-scoped table fixture
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants