Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,49 @@

## Unreleased

- Redesign the Fees page: real Stripe Elements for card entry (`useStripeCardElement.js`,
`stripeLoader.js`), a `HighWaterMarkCard` and `BillingPeriodProgress` making the accrual
mechanics legible instead of a bare number, and an honest `DunningBanner` for a past-due charge
that states the amount and next step rather than a bare "payment failed." No misleading
progress-toward-fee visualization was added — the accrual period is disclosed, never estimated
ahead of the actual charge. `ChargeHistory` and `AccrualSummary` gained page-level loading/empty/
error states instead of managing their own.

- Redesign the Tax Lots page and extend `GET /lots` with unrealized gain/loss, wash-sale
disclosure, and holding-period classification (short/long-term, open lots only). New
`LotsSummaryService` backs the page's totals; `HoldingPeriodBadge`, `ConsumptionHistory` (per-lot
sale detail), and `LotFilterToolbar` replace the previous flat table. Fixed a shared `Table.css`
bug where a wide table forced the whole page to scroll horizontally instead of just the table.

- Redesign the Funding page and 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 computed nowhere the
customer could see them). Add a new `GET /funding/history` endpoint returning per-deposit/
withdrawal rows with real settlement status (`pending`/`confirmed`/`failed`) — a bounced deposit
surfaces as one row with `failure_reason: "ach_return"` rather than a separate correction entry.
Backed by a new shared `deposited_on` helper (`app/services/identity/_shared.py`) so the
cap-enforcement path and this new disclosure path can never compute "deposited today"
differently. Frontend: `OutstandingBalanceBanner`, `DepositLimits`, `BankLinkCard`,
`FundingHistoryTable` are new; fixed a double `GET /cash-summary` fetch per page load and a form
error not reaching `Input`'s `aria-invalid` state.

- Add `GET /api/v1/portfolios/holdings` (real per-security holdings, units/market-value/weight vs.
target, reusing `DriftEvaluationService`) and `GET /api/v1/portfolios/performance` (a live,
as-of-now dated-value series sourced from `sub_period_return`, `range` as an allowlisted enum)
— see ADR 26. Both were previously customer-invisible; the page showed only a model's target
weights, never what the customer actually held.

- Add order-cancellation support: `POST /api/v1/orders/<id>/cancel` requests a broker-side cancel
without locally mutating order status — the authoritative transition still arrives on the
existing trade-updates websocket, so a fill racing a cancel is never lost or double-counted. Also
extend order detail with per-fill quantity/price and the tax lots each fill opened/consumed, and
replace a generic 403 on ineligible order submission with a specific, customer-safe reason. See
ADR 25.

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

- Restore the full set of nav options. A previous revision gated the customer nav on
`useIdentityStatus`, which broke it two ways: the nav rendered **empty** on every page load while
that request was in flight, and any failure or rate-limit on it (capped at 30/min) collapsed an
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""add profile fields to customer

Revision ID: 24a90b294386
Revises: 670073c5146c
Create Date: 2026-09-06 21:52:50.165475

"""
from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = '24a90b294386'
down_revision: str | Sequence[str] | None = '670073c5146c'
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('customer', sa.Column('display_name', sa.String(length=200), nullable=True))
op.add_column('customer', sa.Column('phone', sa.String(length=16), nullable=True))
op.add_column('customer', sa.Column('mailing_address', sa.String(length=500), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('customer', 'mailing_address')
op.drop_column('customer', 'phone')
op.drop_column('customer', 'display_name')
# ### end Alembic commands ###
2 changes: 2 additions & 0 deletions backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from app.controllers.api.lots import lots_bp
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.statements import statements_bp
from app.controllers.api.valuation import valuation_bp
from app.controllers.health import health_bp
Expand Down Expand Up @@ -103,6 +104,7 @@ def create_app(settings: Settings | None = None) -> Flask:
app.register_blueprint(breaks_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(portfolios_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(admin_rebalance_bp)
app.register_blueprint(admin_customers_bp)
app.register_blueprint(admin_kyc_overrides_bp)
Expand Down
53 changes: 50 additions & 3 deletions backend/app/controllers/api/funding.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@
)
from app.core.money import Money # noqa: TC001 -- Pydantic needs the real type at class-build time.
from app.core.uow import SessionRole
from app.core.watermark import Watermark
from app.extensions import DbRole, limiter
from app.integrations.plaid.bank_adapter import PlaidBankAdapter
from app.services.identity import deposit_service as deposit
from app.services.identity import withdrawal_service as withdrawal
from app.services.identity.bank_link_service import BankLinkService
from app.services.identity.funding_history_service import FundingHistoryService
from app.services.identity.funding_summary_service import FundingSummaryService
from app.services.identity.funding_uow import FundingUnitOfWork
from app.services.ledger.cash_policy_service import CashPolicyService
from app.services.orders.holds_provider import OrderHoldsProvider
Expand All @@ -39,6 +42,8 @@
CashSummaryResponse,
CurrentBankLinkResponse,
DepositResponse,
FundingHistoryEntryResponse,
FundingHistoryResponse,
LinkTokenResponse,
WithdrawalResponse,
)
Expand Down Expand Up @@ -154,15 +159,57 @@ def create_link_token() -> Any:
@funding_bp.route("/cash-summary", methods=["GET"])
@limiter.limit("60 per minute")
def get_cash_summary() -> Any:
"""Withdrawable vs. investable cash, via the same `OrderHoldsProvider` withdrawal validates."""
"""Withdrawable vs. investable cash, via the same `OrderHoldsProvider` withdrawal validates,
plus the bounced-deposit receivable balance and deposit-cap visibility (S2 §5.2, FR-6)."""
customer_id = _resolve_customer_id_for_get(request.args.get("customer_id"))
role, uow_customer_id = _session_role_and_customer_id(customer_id)
settings = get_settings()

with FundingUnitOfWork(customer_id=uow_customer_id, role=role, db_role=DbRole.APP) as uow:
cash_policy = CashPolicyService(uow, holds_provider=OrderHoldsProvider(uow))
summary = FundingSummaryService(
uow,
cash_policy=cash_policy,
deposit_cap_per_transaction=settings.deposit_cap_per_transaction,
deposit_cap_per_day=settings.deposit_cap_per_day,
).summarize(customer_id)
view = CashSummaryResponse(
withdrawable=cash_policy.withdrawable(customer_id),
investable=cash_policy.investable(customer_id),
withdrawable=summary.withdrawable,
investable=summary.investable,
outstanding_receivable=summary.outstanding_receivable,
deposit_cap_per_transaction=summary.deposit_cap_per_transaction,
deposit_cap_per_day=summary.deposit_cap_per_day,
deposited_today=summary.deposited_today,
)

return jsonify(view.model_dump(mode="json")), 200


@funding_bp.route("/history", methods=["GET"])
@limiter.limit("60 per minute")
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)

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())
view = FundingHistoryResponse(
entries=[
FundingHistoryEntryResponse(
journal_entry_id=e.journal_entry_id,
entry_type=e.entry_type.value,
effective_date=e.effective_date,
recorded_at=e.recorded_at,
amount=e.amount,
settlement_status=(
e.settlement_status.value if e.settlement_status is not None else None
),
expected_settlement_date=e.expected_settlement_date,
failure_reason=e.failure_reason,
)
for e in entries
]
)

return jsonify(view.model_dump(mode="json")), 200
Expand Down
75 changes: 42 additions & 33 deletions backend/app/controllers/api/lots.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tax lot routes (S8 §3, data owned by S5) -- `GET /api/v1/lots`: quantity, cost basis (original
and wash-sale-adjusted), realized gains, and provisional flags for every lot.
and wash-sale-adjusted), realized gains, provisional flags, current market value/unrealized gain,
wash-sale-disallowed loss disclosure, and per-sale consumption detail for every lot.

Reads `adjusted_basis`/`realized_gain_loss` (already wash-sale-adjusted, S5 §5); never
`original_cost_basis` except as the immutable "as purchased" reference (S5 §7 edge case 1).
Expand All @@ -8,20 +9,22 @@
from __future__ import annotations

import uuid
from collections import defaultdict
from datetime import UTC, datetime
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.clock import MarketClock
from app.core.errors import UnauthenticatedError, ValidationError
from app.core.money import Money
from app.core.security import requires_role
from app.core.uow import SessionRole
from app.extensions import limiter
from app.models.marketdata.trading_calendar import CachedTradingCalendar
from app.services.lots.lots_summary_service import LotsSummaryService, LotSummary
from app.services.lots.uow import LotsUnitOfWork
from app.views.lots import LotResponse, LotsListResponse
from app.views.lots import LotConsumptionResponse, LotResponse, LotsListResponse

lots_bp = Blueprint("lots", __name__, url_prefix="/api/v1")

Expand Down Expand Up @@ -53,41 +56,47 @@ def _uow_customer_id(customer_id: uuid.UUID) -> uuid.UUID | None:
return customer_id if _session_role() is SessionRole.CUSTOMER else None


def _lot_to_response(item: LotSummary) -> LotResponse:
return LotResponse(
id=str(item.lot.id),
security_id=str(item.lot.security_id),
symbol=item.symbol,
quantity_opened=item.lot.quantity_opened,
quantity_remaining=item.lot.quantity_remaining,
original_cost_basis=item.lot.original_cost_basis,
adjusted_basis=item.lot.adjusted_basis,
realized_gain_loss=item.realized_gain_loss,
is_provisional=item.is_provisional,
acquired_at=item.lot.acquired_at,
designation=item.lot.designation.value,
designation_window_closes_at=item.lot.designation_window_closes_at,
current_price=item.current_price,
market_value=item.market_value,
unrealized_gain_loss=item.unrealized_gain_loss,
wash_sale_disallowed=item.wash_sale_disallowed,
consumptions=[
LotConsumptionResponse(
id=str(consumption.id),
sale_date=consumption.sale_date,
quantity_consumed=consumption.quantity_consumed,
realized_gain_loss=consumption.realized_gain_loss,
is_provisional=consumption.is_provisional,
)
for consumption in item.consumptions
],
)


@lots_bp.route("/lots", methods=["GET"])
@limiter.limit("60 per minute")
@requires_role("customer", *_STAFF_ROLES)
def list_lots() -> Any:
customer_id = _resolve_customer_id()
with LotsUnitOfWork(customer_id=_uow_customer_id(customer_id), role=_session_role()) as uow:
lots = uow.tax_lots.list_for_customer(customer_id)
consumptions_by_lot: dict[uuid.UUID, list[Any]] = defaultdict(list)
for consumption in uow.lot_consumptions.list_for_lots([lot.id for lot in lots]):
consumptions_by_lot[consumption.tax_lot_id].append(consumption)

items = []
for lot in lots:
lot_consumptions = consumptions_by_lot.get(lot.id, [])
realized_gain_loss = Money("0.00")
for consumption in lot_consumptions:
realized_gain_loss += consumption.realized_gain_loss
security = uow.securities.get_by_id(lot.security_id)

items.append(
LotResponse(
id=str(lot.id),
security_id=str(lot.security_id),
symbol=security.symbol if security is not None else "",
quantity_opened=lot.quantity_opened,
quantity_remaining=lot.quantity_remaining,
original_cost_basis=lot.original_cost_basis,
adjusted_basis=lot.adjusted_basis,
realized_gain_loss=realized_gain_loss,
is_provisional=any(c.is_provisional for c in lot_consumptions),
acquired_at=lot.acquired_at,
designation=lot.designation.value,
designation_window_closes_at=lot.designation_window_closes_at,
)
)
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)
return jsonify(view.model_dump(mode="json")), 200
Expand Down
Loading
Loading