From c8da771c73cd3ac0d8b92076283cb06ca9a4e8a9 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 22:45:37 -0700 Subject: [PATCH 1/8] chore(dev): point the Vite API proxy at backend port 5001 The dev server default (5000) collides with macOS AirPlay Receiver; every feature this session runs the backend on 5001 instead. --- frontend/vite.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8e81c77..8207e71 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -7,11 +7,11 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:5000', + target: 'http://localhost:5001', changeOrigin: true, }, '/health': { - target: 'http://localhost:5000', + target: 'http://localhost:5001', changeOrigin: true, }, }, From 1e15b6a2a7a839b7ae817e79ea8f5983d7a92389 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 22:46:20 -0700 Subject: [PATCH 2/8] feat(fees): redesign the Fees page with real Stripe Elements and honest disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fees/components/AccrualSummary.css | 36 +++++ .../fees/components/AccrualSummary.jsx | 36 ++--- .../fees/components/BillingPeriodProgress.css | 31 ++++ .../fees/components/BillingPeriodProgress.jsx | 22 +++ .../fees/components/ChargeHistory.css | 32 +++++ .../fees/components/ChargeHistory.jsx | 71 +++++++--- .../fees/components/DunningBanner.css | 42 +++++- .../fees/components/DunningBanner.jsx | 66 ++++++--- .../fees/components/HighWaterMarkCard.css | 40 ++++++ .../fees/components/HighWaterMarkCard.jsx | 29 ++++ .../fees/components/PaymentMethodForm.css | 59 ++++++-- .../fees/components/PaymentMethodForm.jsx | 133 +++++++++++------- .../features/fees/hooks/usePaymentMethod.js | 46 ++++-- .../fees/hooks/useStripeCardElement.js | 128 +++++++++++++++++ .../src/features/fees/utils/feeSummary.js | 62 ++++++++ frontend/src/pages/FeesPage.css | 39 +++++ frontend/src/pages/FeesPage.jsx | 81 +++++++++-- frontend/src/services/stripeLoader.js | 29 ++++ 18 files changed, 830 insertions(+), 152 deletions(-) create mode 100644 frontend/src/features/fees/components/AccrualSummary.css create mode 100644 frontend/src/features/fees/components/BillingPeriodProgress.css create mode 100644 frontend/src/features/fees/components/BillingPeriodProgress.jsx create mode 100644 frontend/src/features/fees/components/ChargeHistory.css create mode 100644 frontend/src/features/fees/components/HighWaterMarkCard.css create mode 100644 frontend/src/features/fees/components/HighWaterMarkCard.jsx create mode 100644 frontend/src/features/fees/hooks/useStripeCardElement.js create mode 100644 frontend/src/features/fees/utils/feeSummary.js create mode 100644 frontend/src/pages/FeesPage.css create mode 100644 frontend/src/services/stripeLoader.js diff --git a/frontend/src/features/fees/components/AccrualSummary.css b/frontend/src/features/fees/components/AccrualSummary.css new file mode 100644 index 0000000..6a191e4 --- /dev/null +++ b/frontend/src/features/fees/components/AccrualSummary.css @@ -0,0 +1,36 @@ +.tu-accrual-summary { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.tu-accrual-summary__label { + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + color: var(--color-text-secondary); +} + +/* The one extra serif use this screen spends (design-system §3.1: "the single largest number on a + statement"), matching BalanceCard.css's `.tu-balance-card__value` treatment exactly — the two + never co-occur since BalanceCard lives on the dashboard route, not here. */ +.tu-accrual-summary__value { + font-family: var(--font-serif); + font-size: var(--font-size-display); + line-height: var(--line-height-display); + font-weight: var(--font-weight-display); + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +.tu-accrual-summary__explainer { + margin: 0; + max-width: var(--reading-max-width); + font-size: var(--font-size-body); + line-height: var(--line-height-body); + color: var(--color-text-secondary); +} + +.tu-accrual-summary__progress { + margin-top: var(--space-1); + max-width: var(--reading-max-width); +} diff --git a/frontend/src/features/fees/components/AccrualSummary.jsx b/frontend/src/features/fees/components/AccrualSummary.jsx index 12d794d..8022f85 100644 --- a/frontend/src/features/fees/components/AccrualSummary.jsx +++ b/frontend/src/features/fees/components/AccrualSummary.jsx @@ -1,30 +1,20 @@ -import Decimal from 'decimal.js' import { Card } from '../../../components/Card' -import { DetailFields } from '../../../components/DetailFields' -import { EmptyState } from '../../../components/EmptyState' import { formatMoney } from '../../../utils/format.js' +import { BillingPeriodProgress } from './BillingPeriodProgress.jsx' +import './AccrualSummary.css' -// `GET /fees` always returns an accrual object (`FeeSummaryResponse`, backend/app/views/fees.py) -// — accrual is never absent, so the empty state is a real zero-activity check, not a falsy check. -// `high_water_mark` is only created on a customer's first valuation day (HighWaterMarkService -// .get_or_create); until then peak_value is null, there are no charges, and accrual_to_date is zero. -function hasNoActivityYet(accrual, charges) { - return accrual.peak_value === null && charges.length === 0 && new Decimal(accrual.accrual_to_date).isZero() -} - -export function AccrualSummary({ accrual, charges }) { - if (hasNoActivityYet(accrual, charges)) { - return - } - +/** Emptiness is decided once, at the page level (`hasNoFeeActivity`, utils/feeSummary.js) — this + * component only ever renders the loaded, active-accrual state. */ +export function AccrualSummary({ accrual, billingPeriod }) { return ( - - + + Accrued this month + {formatMoney(accrual.accrual_to_date)} +

+ A performance fee is charged only on gains above your account's previous peak. It + accrues day by day and is billed once the billing period closes. +

+
) } diff --git a/frontend/src/features/fees/components/BillingPeriodProgress.css b/frontend/src/features/fees/components/BillingPeriodProgress.css new file mode 100644 index 0000000..3d194a8 --- /dev/null +++ b/frontend/src/features/fees/components/BillingPeriodProgress.css @@ -0,0 +1,31 @@ +.tu-billing-period { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* Square ends, not a pill — design-system §6 restricts radius-full to toggles/avatars. A ruled + measure fits the "vault, not dashboard" vocabulary better than a chip. */ +.tu-billing-period__track { + height: 4px; + border-radius: 0; + background: var(--color-surface-sunken); + overflow: hidden; +} + +/* text-secondary, not --color-accent: §2.4 reserves brass for interactive elements, current + selection, and the restated marker. A progress fill is none of those — using the accent here + would dilute its scarcity everywhere else on the page. */ +.tu-billing-period__fill { + height: 100%; + background: var(--color-text-secondary); + transition: width var(--duration-base) var(--ease-standard); +} + +.tu-billing-period__caption { + margin: 0; + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} diff --git a/frontend/src/features/fees/components/BillingPeriodProgress.jsx b/frontend/src/features/fees/components/BillingPeriodProgress.jsx new file mode 100644 index 0000000..df28265 --- /dev/null +++ b/frontend/src/features/fees/components/BillingPeriodProgress.jsx @@ -0,0 +1,22 @@ +import { formatDate } from '../../../utils/format.js' +import './BillingPeriodProgress.css' + +/** + * How far into the current calendar-month billing period today is. `period` comes from + * `getBillingPeriod()` (utils/feeSummary.js) — pure date math, no valuation call. The bar is + * `aria-hidden`: the caption below it carries the whole meaning in words, so there's no + * color-only/graphic-only signal to separately account for (design-system §2.4, §9). + */ +export function BillingPeriodProgress({ period, className = '' }) { + return ( +
+ + ) +} diff --git a/frontend/src/features/fees/components/ChargeHistory.css b/frontend/src/features/fees/components/ChargeHistory.css new file mode 100644 index 0000000..eab28f4 --- /dev/null +++ b/frontend/src/features/fees/components/ChargeHistory.css @@ -0,0 +1,32 @@ +.tu-charge-history__summary { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-2); + padding-bottom: var(--space-3); + margin-bottom: var(--space-3); + border-bottom: 1px solid var(--color-hairline); +} + +.tu-charge-history__summary-label { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +/* Never text-muted (design-system §2.3): this is a money figure. */ +.tu-charge-history__summary-value { + font-size: var(--font-size-data); + font-weight: var(--font-weight-data-strong); + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +.tu-charge-history__summary-meta { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +.tu-charge-history__reference { + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} diff --git a/frontend/src/features/fees/components/ChargeHistory.jsx b/frontend/src/features/fees/components/ChargeHistory.jsx index 130b30d..7dc36de 100644 --- a/frontend/src/features/fees/components/ChargeHistory.jsx +++ b/frontend/src/features/fees/components/ChargeHistory.jsx @@ -1,38 +1,63 @@ import { EmptyState } from '../../../components/EmptyState' +import { Icon } from '../../../components/Icon' import { StatusPill } from '../../../components/StatusPill' import { Table } from '../../../components/Table' import { formatDate, formatMoney } from '../../../utils/format.js' +import { sumSucceededCharges } from '../utils/feeSummary.js' +import './ChargeHistory.css' const TONE_BY_STATUS = { pending: 'warning', succeeded: 'success', failed: 'error', dunning: 'error' } const LABEL_BY_STATUS = { pending: 'Pending', succeeded: 'Succeeded', failed: 'Failed', dunning: 'Dunning' } +// Belt-and-braces alongside the tone + label word (design-system §2.4: semantic color is never the +// only signal) — 'history' reads as a clock face, standing in for "not yet resolved." +const ICON_BY_STATUS = { pending: 'history', succeeded: 'check-circle', failed: 'alert-circle', dunning: 'alert-circle' } export function ChargeHistory({ charges }) { if (charges.length === 0) { - return + return } + const { total, count } = sumSucceededCharges(charges) + return ( - - - Period - Amount - Status - - - {charges.map((charge) => ( - - - {formatDate(charge.billing_period_start)} – {formatDate(charge.billing_period_end)} - - - {formatMoney(charge.total_accrued)} - - - {LABEL_BY_STATUS[charge.status] || charge.status} - - - ))} - -
+ <> + {count > 0 && ( +
+ Total paid to date + {formatMoney(total)} + + across {count} {count === 1 ? 'charge' : 'charges'} + +
+ )} + + + Period + Amount + Status + Reference + + + {charges.map((charge) => ( + + + {formatDate(charge.billing_period_start)} – {formatDate(charge.billing_period_end)} + + + {formatMoney(charge.total_accrued)} + + + }> + {LABEL_BY_STATUS[charge.status] || charge.status} + + + + {charge.stripe_charge_id ?? '—'} + + + ))} + +
+ ) } diff --git a/frontend/src/features/fees/components/DunningBanner.css b/frontend/src/features/fees/components/DunningBanner.css index 3a66f4c..fd0864e 100644 --- a/frontend/src/features/fees/components/DunningBanner.css +++ b/frontend/src/features/fees/components/DunningBanner.css @@ -1,21 +1,51 @@ +/* Hairline-and-rule vocabulary (§6) — a left accent rule does the emphasis, no shadow. */ .tu-dunning-banner { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-3); + align-items: start; padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); - font-size: var(--font-size-body); } .tu-dunning-banner--warning { background: var(--color-warning-subtle); - color: var(--color-text); + border-left: 3px solid var(--color-warning); + color: var(--color-warning); } .tu-dunning-banner--error { background: var(--color-error-subtle); + border-left: 3px solid var(--color-error); + color: var(--color-error); +} + +/* The icon inherits the modifier's color via currentColor; the body text below overrides back to + the primary text color so only the icon (and the rule) carry the semantic tint. */ +.tu-dunning-banner__icon { + display: flex; + padding-top: 2px; +} + +.tu-dunning-banner__body { + display: flex; + flex-direction: column; + gap: var(--space-3); color: var(--color-text); } -.tu-dunning-banner__link { - color: var(--color-accent); - font-weight: var(--font-weight-body-medium); - text-decoration: underline; +.tu-dunning-banner__message { + margin: 0; + font-size: var(--font-size-body); + line-height: var(--line-height-body); +} + +.tu-dunning-banner__actions { + display: flex; +} + +@media (max-width: 479px) { + .tu-dunning-banner__actions .tu-button { + width: 100%; + } } diff --git a/frontend/src/features/fees/components/DunningBanner.jsx b/frontend/src/features/fees/components/DunningBanner.jsx index 2258bb3..05835f5 100644 --- a/frontend/src/features/fees/components/DunningBanner.jsx +++ b/frontend/src/features/fees/components/DunningBanner.jsx @@ -1,32 +1,58 @@ +import { Button } from '../../../components/Button' +import { Icon } from '../../../components/Icon' import { formatDateTime } from '../../../utils/format.js' import './DunningBanner.css' -/** Design system §8.9 escalation: first miss warning, exhausted error. */ -export function DunningBanner({ dunning }) { - if (!dunning || dunning.status !== 'retrying' && dunning.status !== 'exhausted') { +const CONFIG_BY_STATUS = { + retrying: { + modifier: 'warning', + icon: 'alert-triangle', + // A first miss shouldn't interrupt a screen-reader user mid-sentence the way an + // action-required exhausted state should (design-system §8.9's escalation rule, expressed in + // the accessibility layer too). + ariaLive: 'polite', + }, + exhausted: { + modifier: 'error', + icon: 'alert-circle', + ariaLive: 'assertive', + }, +} + +/** Design system §8.9 escalation: first miss is a warning, exhausted is an error — always with a + * specific next step, never a bare "payment failed." */ +export function DunningBanner({ dunning, onUpdatePaymentMethod }) { + if (!dunning || (dunning.status !== 'retrying' && dunning.status !== 'exhausted')) { return null } - if (dunning.status === 'exhausted') { - return ( -
- Payment failed after {dunning.max_attempts} attempts.{' '} - - Update your payment method - {' '} - to keep your account in good standing. -
+ const { modifier, icon, ariaLive } = CONFIG_BY_STATUS[dunning.status] + const message = + dunning.status === 'exhausted' ? ( + <> + Payment failed after {dunning.max_attempts} attempts. We won't retry automatically — add a + working card to keep your account in good standing. + + ) : ( + <> + A fee payment failed (attempt {dunning.attempt_number} of {dunning.max_attempts}) — we'll + retry on {formatDateTime(dunning.next_retry_at)}. + ) - } return ( -
- A fee payment failed (attempt {dunning.attempt_number} of {dunning.max_attempts}) — we'll retry on{' '} - {formatDateTime(dunning.next_retry_at)}. You can also{' '} - - update your payment method - {' '} - now. +
+ + + +
+

{message}

+
+ +
+
) } diff --git a/frontend/src/features/fees/components/HighWaterMarkCard.css b/frontend/src/features/fees/components/HighWaterMarkCard.css new file mode 100644 index 0000000..d4176b2 --- /dev/null +++ b/frontend/src/features/fees/components/HighWaterMarkCard.css @@ -0,0 +1,40 @@ +.tu-high-water-mark { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.tu-high-water-mark__label { + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + color: var(--color-text-secondary); +} + +/* Sans, not serif: this screen's one allowed extra serif use (design-system §3.1, "the single + largest number on a statement") is already spent on the accrual hero in AccrualSummary.css. */ +.tu-high-water-mark__value { + font-family: var(--font-sans); + font-size: var(--font-size-heading-sm); + line-height: var(--line-height-heading-sm); + font-weight: var(--font-weight-heading-sm); + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +.tu-high-water-mark__value--unset { + color: var(--color-text-secondary); +} + +.tu-high-water-mark__explainer { + margin: 0; + max-width: var(--reading-max-width); + font-size: var(--font-size-body); + line-height: var(--line-height-body); + color: var(--color-text-secondary); +} + +.tu-high-water-mark__updated { + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + color: var(--color-text-muted); +} diff --git a/frontend/src/features/fees/components/HighWaterMarkCard.jsx b/frontend/src/features/fees/components/HighWaterMarkCard.jsx new file mode 100644 index 0000000..c3b8da9 --- /dev/null +++ b/frontend/src/features/fees/components/HighWaterMarkCard.jsx @@ -0,0 +1,29 @@ +import { Card } from '../../../components/Card' +import { formatDate, formatMoney } from '../../../utils/format.js' +import './HighWaterMarkCard.css' + +/** + * The high-water mark explained in plain words, not a percentage — `FEE_RATE_PCT` is a deploy-time + * setting (`docs/specs/10-performance-fees.md` §"the fee rate's actual value") and never reaches + * the wire, so this component never invents one in copy. + */ +export function HighWaterMarkCard({ accrual }) { + const hasPeak = accrual.peak_value !== null + + return ( + + High-water mark + {hasPeak ? ( + {formatMoney(accrual.peak_value)} + ) : ( + Not set yet + )} +

+ {hasPeak + ? "You're only charged on gains above this line. If your account is below it, no fee accrues until it recovers past this figure." + : 'Your high-water mark is recorded on your first valuation day.'} +

+ {accrual.updated_at && Updated {formatDate(accrual.updated_at)}} +
+ ) +} diff --git a/frontend/src/features/fees/components/PaymentMethodForm.css b/frontend/src/features/fees/components/PaymentMethodForm.css index 9d8d85f..1776a59 100644 --- a/frontend/src/features/fees/components/PaymentMethodForm.css +++ b/frontend/src/features/fees/components/PaymentMethodForm.css @@ -1,12 +1,29 @@ +/* Single column now (real Stripe Elements' card field replaces the old two-column + current-method/form layout) — capped at the reading width per design-system §5's prose/form + column rule, so one field doesn't stretch to the full page width. */ .tu-payment-method-form { - display: grid; - grid-template-columns: minmax(180px, 280px) minmax(240px, 360px); - gap: var(--space-6); - align-items: start; + display: flex; + flex-direction: column; + gap: var(--space-4); + max-width: var(--reading-max-width); +} + +.tu-payment-method-form__current { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-hairline); +} + +.tu-payment-method-form__current-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); } .tu-payment-method-form__current-label { - margin: 0 0 var(--space-1); font-size: var(--font-size-caption); color: var(--color-text-secondary); } @@ -21,14 +38,30 @@ color: var(--color-text-secondary); } -.tu-payment-method-form__fields { - display: flex; - flex-direction: column; - gap: var(--space-4); +.tu-payment-method-form__current-caption { + margin: 0; + font-size: var(--font-size-caption); + color: var(--color-text-muted); +} + +/* A Stripe iframe can't live inside a real , so this div is the mount point styled to + match .tu-field__control (Input.css) exactly, for visual consistency with every other field in + the app. */ +.tu-card-field__control { + min-height: 40px; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-strong); + background: var(--color-surface); +} + +/* Our CSS can't reach `:focus-within` across the iframe boundary, so the hook toggles this + data-attribute on focus/blur instead — same convention as Table's data-align/data-clickable. */ +.tu-card-field__control[data-focused='true'] { + outline: 2px solid var(--color-accent); + outline-offset: 2px; } -@media (max-width: 560px) { - .tu-payment-method-form { - grid-template-columns: 1fr; - } +.tu-field--error .tu-card-field__control { + border-color: var(--color-error); } diff --git a/frontend/src/features/fees/components/PaymentMethodForm.jsx b/frontend/src/features/fees/components/PaymentMethodForm.jsx index a5ad77e..4fb590c 100644 --- a/frontend/src/features/fees/components/PaymentMethodForm.jsx +++ b/frontend/src/features/fees/components/PaymentMethodForm.jsx @@ -1,71 +1,110 @@ -import { useState } from 'react' +import { useCallback, useId } from 'react' +import { Badge } from '../../../components/Badge' import { Button } from '../../../components/Button' -import { Select } from '../../../components/Select' +import { ErrorState } from '../../../components/ErrorState' +import { Skeleton } from '../../../components/Skeleton' import { useToast } from '../../../components/Toast' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { useStripeCardElement } from '../hooks/useStripeCardElement.js' import { usePaymentMethod } from '../hooks/usePaymentMethod.js' import './PaymentMethodForm.css' -// Stripe fixed test-mode PaymentMethod ids (docs.stripe.com/testing). -const TEST_PAYMENT_METHODS = { - visa: { label: 'Visa', paymentMethodId: 'pm_card_visa', last4: '4242' }, - mastercard: { label: 'Mastercard', paymentMethodId: 'pm_card_mastercard', last4: '4444' }, - amex: { label: 'American Express', paymentMethodId: 'pm_card_amex', last4: '0005' }, - discover: { label: 'Discover', paymentMethodId: 'pm_card_discover', last4: '1117' }, +const BRAND_LABELS = { visa: 'Visa', mastercard: 'Mastercard', amex: 'American Express', discover: 'Discover' } + +function brandLabel(brand) { + return BRAND_LABELS[brand] || (brand ? brand[0].toUpperCase() + brand.slice(1) : 'Card') } +/** Real Stripe Elements card entry (client-side tokenization — Trueup never handles raw card + * data, PCI scope stays with Stripe) via `@stripe/stripe-js` used imperatively: no + * `@stripe/react-stripe-js` dependency, since one field doesn't need a React provider on top of + * the vanilla API this app already depends on. */ export function PaymentMethodForm({ customerId, currentPaymentMethod, onAttached }) { - const { status, error, attach } = usePaymentMethod(customerId) + const { status, errorMessage, save, reset } = usePaymentMethod(customerId) + const { containerRef, status: cardStatus, cardElement, stripe, cardError, retry } = useStripeCardElement({ + onChange: reset, + }) const { showToast } = useToast() - const [brandKey, setBrandKey] = useState('visa') + const labelId = useId() const isSubmitting = status === 'submitting' + const message = errorMessage || cardError + // `cardError` gates submission too -- Stripe's own `change` event already told us this card is + // invalid, so there's no reason to round-trip a submit attempt that createPaymentMethod would + // just reject a second time. + const canSubmit = cardStatus === 'ready' && Boolean(stripe) && Boolean(cardElement) && !isSubmitting && !cardError - const handleSubmit = (event) => { - event.preventDefault() - const chosen = TEST_PAYMENT_METHODS[brandKey] - attach(chosen.paymentMethodId) - .then(() => { - showToast({ message: 'Payment method saved.', tone: 'success' }) - onAttached?.({ brand: chosen.label, last4: chosen.last4 }) - }) - .catch(() => {}) - } + const handleSubmit = useCallback( + async (event) => { + event.preventDefault() + if (!canSubmit) return - const displayError = status === 'error' ? getErrorMessage(error) : null + const saved = await save({ stripe, cardElement }) + if (saved) { + showToast({ message: 'Payment method saved.', tone: 'success' }) + cardElement.clear() + onAttached?.({ brand: saved.brand, last4: saved.last4 }) + } + }, + [canSubmit, save, stripe, cardElement, showToast, onAttached], + ) return (
-

Current payment method

+
+ Card on file + {!currentPaymentMethod && Not shown} +
{currentPaymentMethod ? ( -

- {currentPaymentMethod.brand} ending in {currentPaymentMethod.last4} -

+ <> +

+ {brandLabel(currentPaymentMethod.brand)} ending in {currentPaymentMethod.last4} +

+

+ Saved in this session. Trueup doesn't read card details back from Stripe, so this + reflects only the card you added here. +

+ ) : ( -

- No payment method on file yet. -

+ <> +

+ Trueup doesn't display saved card details. +

+

+ Stripe holds your card; this page can't read it back, so a card you added in an + earlier session won't appear here. Saving a card below replaces whatever is + currently on file. +

+ )}
-
- - -
+ + {cardStatus === 'error' ? ( + + ) : ( +
+ + Card details + + {cardStatus === 'loading' && } + + )} + + ) } diff --git a/frontend/src/features/fees/hooks/usePaymentMethod.js b/frontend/src/features/fees/hooks/usePaymentMethod.js index 32952ad..bf28d61 100644 --- a/frontend/src/features/fees/hooks/usePaymentMethod.js +++ b/frontend/src/features/fees/hooks/usePaymentMethod.js @@ -1,26 +1,54 @@ import { useCallback, useState } from 'react' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { feesApi } from '../api/feesApi.js' +/** + * Owns the whole tokenize-then-attach sequence (mirrors `useKycSession.js`'s ownership of both + * the Stripe call and the API call) so the two outcomes that must always agree — "did Stripe + * accept the card" and "did the backend save it" — live as one state machine, not two booleans a + * component could let drift apart. + */ export function usePaymentMethod(customerId) { const [status, setStatus] = useState('idle') - const [error, setError] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) - const attach = useCallback( - async (paymentMethodId) => { + const save = useCallback( + async ({ stripe, cardElement }) => { setStatus('submitting') - setError(null) + setErrorMessage(null) + + const { paymentMethod, error: stripeError } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement, + }) + if (stripeError) { + // A declined/invalid card is an expected outcome here, not an exception — Stripe's own + // message is already specific and customer-safe. + setErrorMessage(stripeError.message) + setStatus('error') + return null + } + try { - const data = await feesApi.attachPaymentMethod(customerId, paymentMethodId) + const data = await feesApi.attachPaymentMethod(customerId, paymentMethod.id) setStatus('submitted') - return data + return { brand: paymentMethod.card.brand, last4: paymentMethod.card.last4, updatedAt: data.updated_at } } catch (err) { - setError(err) + // Today a Stripe-side decline at attach time surfaces from the backend as a generic + // internal_error (no card_declined code exists yet — see the plan's note to flag this to + // main as a backend follow-up); this scoped fallback is the frontend-side mitigation. + setErrorMessage(getErrorMessage(err, "We couldn't save that card. Check the details, or try a different card.")) setStatus('error') - throw err + return null } }, [customerId], ) - return { status, error, attach } + const reset = useCallback(() => { + setStatus('idle') + setErrorMessage(null) + }, []) + + return { status, errorMessage, save, reset } } diff --git a/frontend/src/features/fees/hooks/useStripeCardElement.js b/frontend/src/features/fees/hooks/useStripeCardElement.js new file mode 100644 index 0000000..685c449 --- /dev/null +++ b/frontend/src/features/fees/hooks/useStripeCardElement.js @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getStripe } from '../../../services/stripeLoader.js' + +function readToken(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() +} + +// Stripe's Elements render inside an iframe and cannot read our CSS custom properties directly — +// this resolves the current theme's actual color values at call time. Re-run on every theme flip +// (see the useEffect below) so the field repaints instead of freezing at whichever theme was +// active when it first mounted. +function buildCardStyle() { + return { + base: { + color: readToken('--color-text'), + fontFamily: "'Public Sans', system-ui, -apple-system, sans-serif", + fontSize: '15px', + fontWeight: '400', + // No `lineHeight` here -- Stripe's own docs discourage it (inconsistent across browsers) + // in favor of sizing the field via the container's padding, which .tu-card-field__control + // already does (var(--space-2) top/bottom against the 40px control-height floor, §7.2). + iconColor: readToken('--color-text-secondary'), + '::placeholder': { color: readToken('--color-text-muted') }, + }, + invalid: { + color: readToken('--color-error'), + iconColor: readToken('--color-error'), + }, + } +} + +/** + * Owns the whole lifecycle of a single Stripe `card` Element: loading Stripe.js, mounting it into + * `containerRef`, tracking ready/validation/focus state, keeping its style in sync with the app's + * light/dark theme, and tearing it down on unmount. `onChange` fires on every Stripe `change` + * event — `usePaymentMethod` uses it to clear a stale submit/error state as soon as the customer + * edits the card (the fix for the form getting stuck after a save or a failure). + */ +export function useStripeCardElement({ onChange } = {}) { + const containerRef = useRef(null) + const stripeRef = useRef(null) + const cardRef = useRef(null) + const [status, setStatus] = useState('loading') + const [cardError, setCardError] = useState(null) + const [loadError, setLoadError] = useState(null) + const [reloadKey, setReloadKey] = useState(0) + + useEffect(() => { + let cancelled = false + setStatus('loading') + setLoadError(null) + + getStripe() + .then((stripe) => { + if (cancelled || !stripe || !containerRef.current) return + + const elements = stripe.elements() + const card = elements.create('card', { style: buildCardStyle(), hidePostalCode: false }) + card.mount(containerRef.current) + + card.on('ready', () => { + if (cancelled) return + setStatus('ready') + }) + card.on('change', (event) => { + if (cancelled) return + setCardError(event.error?.message ?? null) + onChange?.() + }) + card.on('focus', () => { + if (containerRef.current) containerRef.current.dataset.focused = 'true' + }) + card.on('blur', () => { + if (containerRef.current) containerRef.current.dataset.focused = 'false' + }) + + stripeRef.current = stripe + cardRef.current = card + }) + .catch((error) => { + if (cancelled) return + setLoadError(error) + setStatus('error') + }) + + return () => { + cancelled = true + cardRef.current?.unmount() + cardRef.current?.destroy() + cardRef.current = null + stripeRef.current = null + } + }, [reloadKey, onChange]) + + useEffect(() => { + function syncTheme() { + cardRef.current?.update({ style: buildCardStyle() }) + } + + // An explicit in-app theme toggle changes documentElement's data-theme attribute; the OS-level + // preference changes independently. Both need to reach the iframe. + const observer = new MutationObserver(syncTheme) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + const media = window.matchMedia('(prefers-color-scheme: dark)') + media.addEventListener('change', syncTheme) + + return () => { + observer.disconnect() + media.removeEventListener('change', syncTheme) + } + }, []) + + const retry = useCallback(() => setReloadKey((key) => key + 1), []) + + return { + containerRef, + status, + // Reading the refs at render time is safe here: they're only ever populated just before the + // `card.on('ready', ...)` handler calls `setStatus('ready')`, which is what triggers this + // render in the first place — by the time a consumer sees `status === 'ready'`, both refs are + // already set. + cardElement: cardRef.current, + stripe: stripeRef.current, + cardError, + loadError, + retry, + } +} diff --git a/frontend/src/features/fees/utils/feeSummary.js b/frontend/src/features/fees/utils/feeSummary.js new file mode 100644 index 0000000..bd86598 --- /dev/null +++ b/frontend/src/features/fees/utils/feeSummary.js @@ -0,0 +1,62 @@ +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`). + +// `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. +// `high_water_mark` is only created on a customer's first valuation day (HighWaterMarkService +// .get_or_create); until then peak_value is null, there are no charges, and accrual_to_date is zero. +export function hasNoFeeActivity(accrual, charges) { + return accrual.peak_value === null && charges.length === 0 && new Decimal(accrual.accrual_to_date).isZero() +} + +function toIsoDate(date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** + * A performance fee's billing period is a calendar month (`docs/specs/10-performance-fees.md` + * §5). This is pure date math — never a call to the balance/valuation API — deliberately: the fee + * summary has no field for the customer's current portfolio value, and approximating one from + * elsewhere would risk implying "progress toward a fee" from a plain deposit, which is exactly + * the thing the backend's TWR-adjusted high-water-mark math exists to prevent (spec §8, edge case + * 2). Built with the local 3-arg `Date` constructor throughout, never a parsed ISO string — see + * `utils/format.js`'s `formatDate` for the UTC-midnight trap this avoids. Returns `YYYY-MM-DD` + * strings (not `Date` objects) so callers route into `formatDate`'s timezone-safe plain-date + * branch. + */ +export function getBillingPeriod(now = new Date()) { + const year = now.getFullYear() + const month = now.getMonth() + + const startDate = new Date(year, month, 1) + const endDate = new Date(year, month + 1, 0) + const nextChargeDate = new Date(year, month + 1, 1) + + const dayOfPeriod = now.getDate() + const totalDays = endDate.getDate() + const percentElapsed = Math.min(100, Math.max(0, (dayOfPeriod / totalDays) * 100)) + + return { + startDate: toIsoDate(startDate), + endDate: toIsoDate(endDate), + nextChargeDate: toIsoDate(nextChargeDate), + dayOfPeriod, + totalDays, + percentElapsed, + } +} + +/** Sum of `succeeded` charges only — a pending or failed charge is not money paid. Money is a wire + * string throughout; arithmetic goes through decimal.js, never native +/* on a float. */ +export function sumSucceededCharges(charges) { + const succeeded = charges.filter((charge) => charge.status === 'succeeded') + const total = succeeded.reduce((sum, charge) => sum.plus(charge.total_accrued), new Decimal(0)) + return { total: total.toFixed(2), count: succeeded.length } +} diff --git a/frontend/src/pages/FeesPage.css b/frontend/src/pages/FeesPage.css new file mode 100644 index 0000000..b406d00 --- /dev/null +++ b/frontend/src/pages/FeesPage.css @@ -0,0 +1,39 @@ +.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 index 0a862e7..4b79dcb5 100644 --- a/frontend/src/pages/FeesPage.jsx +++ b/frontend/src/pages/FeesPage.jsx @@ -1,24 +1,62 @@ +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

- +
) } @@ -32,20 +70,41 @@ export function FeesPage() { ) } + const isEmpty = hasNoFeeActivity(accrual, charges) + return (
-

Fees

-
-

Accrual

- -
-
-

Charge history

- +

Fees

+

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

-
-

Payment method

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

High-water mark

+ +
+
+

Charge history

+ +
+
+ + )} +
+

+ Payment method +

diff --git a/frontend/src/services/stripeLoader.js b/frontend/src/services/stripeLoader.js new file mode 100644 index 0000000..3f220d5 --- /dev/null +++ b/frontend/src/services/stripeLoader.js @@ -0,0 +1,29 @@ +import { loadStripe } from '@stripe/stripe-js' +import { apiClient } from './apiClient.js' + +// Stripe.js singleton, shared across any feature that needs it (fees' card element today; +// onboarding's KYC step has its own copy of this same pattern in useKycSession.js). Lives in +// services/ rather than a feature's api/ module so no feature has to import another feature's API +// client just to read a provider config value (frontend/CLAUDE.md: promote to a global folder +// only once a second feature needs it — this is that second feature). +let stripePromise = null + +/** + * Resolves to a ready Stripe.js instance, fetching the publishable key from + * `GET /api/v1/identity/config` (public, not customer-scoped) on first call. Only the *resolved* + * promise is cached — a rejection clears it, so a transient failure (e.g. `STRIPE_PUBLISHABLE_KEY` + * unset server-side, which makes `/identity/config` 500) doesn't poison every later attempt for + * the life of the tab. + */ +export function getStripe() { + if (!stripePromise) { + stripePromise = apiClient + .get('/identity/config') + .then((config) => loadStripe(config.stripe_publishable_key)) + .catch((error) => { + stripePromise = null + throw error + }) + } + return stripePromise +} From 3590b16a2ed6b58af664b0fdfa95c911daa15c13 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 22:46:35 -0700 Subject: [PATCH 3/8] feat(lots): redesign the Tax Lots page and add gain/loss + wash-sale 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. --- backend/app/controllers/api/lots.py | 75 ++-- backend/app/models/ledger/lot_consumption.py | 19 +- backend/app/models/ledger/tax_lot.py | 7 + .../app/models/ledger/wash_sale_adjustment.py | 14 + backend/app/models/marketdata/security.py | 11 +- .../app/services/lots/lots_summary_service.py | 117 ++++++ backend/app/views/lots.py | 27 +- backend/tests/api/test_lots.py | 386 +++++++++++++++++- .../integration/test_lots_summary_service.py | 358 ++++++++++++++++ frontend/src/components/Table/Table.css | 8 + frontend/src/features/lots/api/lotsApi.js | 61 +-- .../lots/components/ConsumptionHistory.css | 15 + .../lots/components/ConsumptionHistory.jsx | 45 ++ .../lots/components/HoldingPeriodBadge.css | 18 + .../lots/components/HoldingPeriodBadge.jsx | 32 ++ .../features/lots/components/LotDetail.css | 22 +- .../features/lots/components/LotDetail.jsx | 37 +- .../lots/components/LotFilterToolbar.css | 8 + .../lots/components/LotFilterToolbar.jsx | 30 ++ .../lots/components/LotStatusBadge.jsx | 11 + .../features/lots/components/LotSummary.css | 72 ++++ .../features/lots/components/LotSummary.jsx | 52 +++ .../src/features/lots/components/LotTable.css | 13 + .../src/features/lots/components/LotTable.jsx | 102 +++-- frontend/src/features/lots/hooks/useLots.js | 23 +- .../src/features/lots/utils/lotSummary.js | 104 +++++ frontend/src/pages/LotsPage.css | 28 ++ frontend/src/pages/LotsPage.jsx | 77 +++- 28 files changed, 1625 insertions(+), 147 deletions(-) create mode 100644 backend/app/services/lots/lots_summary_service.py create mode 100644 backend/tests/integration/test_lots_summary_service.py create mode 100644 frontend/src/features/lots/components/ConsumptionHistory.css create mode 100644 frontend/src/features/lots/components/ConsumptionHistory.jsx create mode 100644 frontend/src/features/lots/components/HoldingPeriodBadge.css create mode 100644 frontend/src/features/lots/components/HoldingPeriodBadge.jsx create mode 100644 frontend/src/features/lots/components/LotFilterToolbar.css create mode 100644 frontend/src/features/lots/components/LotFilterToolbar.jsx create mode 100644 frontend/src/features/lots/components/LotStatusBadge.jsx create mode 100644 frontend/src/features/lots/components/LotSummary.css create mode 100644 frontend/src/features/lots/components/LotSummary.jsx create mode 100644 frontend/src/features/lots/utils/lotSummary.js create mode 100644 frontend/src/pages/LotsPage.css diff --git a/backend/app/controllers/api/lots.py b/backend/app/controllers/api/lots.py index 81fa7a5..0431bba 100644 --- a/backend/app/controllers/api/lots.py +++ b/backend/app/controllers/api/lots.py @@ -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). @@ -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") @@ -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 diff --git a/backend/app/models/ledger/lot_consumption.py b/backend/app/models/ledger/lot_consumption.py index c16909a..d24e168 100644 --- a/backend/app/models/ledger/lot_consumption.py +++ b/backend/app/models/ledger/lot_consumption.py @@ -85,11 +85,26 @@ def list_realized_in_period( ) return list(self.session.execute(statement).scalars().all()) + def list_by_closing_execution_ids(self, execution_ids: Sequence[str]) -> list[LotConsumption]: + """Every lot consumption a sell order's own fills drew from (S3 §6 order-detail lot + linkage), one query.""" + if not execution_ids: + return [] + statement = select(LotConsumption).where( + LotConsumption.closing_fill_execution_id.in_(execution_ids) + ) + return list(self.session.execute(statement).scalars().all()) + def list_for_lots(self, tax_lot_ids: Sequence[uuid.UUID]) -> list[LotConsumption]: - """Every consumption row against any of the given lots, in one query (S8 §3).""" + """Every consumption row against any of the given lots, most-recent sale first + (GET /api/v1/lots).""" if not tax_lot_ids: return [] - statement = select(LotConsumption).where(LotConsumption.tax_lot_id.in_(tax_lot_ids)) + statement = ( + select(LotConsumption) + .where(LotConsumption.tax_lot_id.in_(tax_lot_ids)) + .order_by(LotConsumption.sale_date.desc(), LotConsumption.id.desc()) + ) return list(self.session.execute(statement).scalars().all()) def find_unadjusted_losses_in_window( diff --git a/backend/app/models/ledger/tax_lot.py b/backend/app/models/ledger/tax_lot.py index 95a17c8..4151d05 100644 --- a/backend/app/models/ledger/tax_lot.py +++ b/backend/app/models/ledger/tax_lot.py @@ -167,6 +167,13 @@ def list_customers_holding(self, security_id: uuid.UUID) -> list[uuid.UUID]: ) return list(self.session.execute(statement).scalars().all()) + def list_by_opening_execution_ids(self, execution_ids: Sequence[str]) -> list[TaxLot]: + """Every lot a buy order's own fills opened (S3 §6 order-detail lot linkage), one query.""" + if not execution_ids: + return [] + 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 = ( diff --git a/backend/app/models/ledger/wash_sale_adjustment.py b/backend/app/models/ledger/wash_sale_adjustment.py index a6381a8..1a69142 100644 --- a/backend/app/models/ledger/wash_sale_adjustment.py +++ b/backend/app/models/ledger/wash_sale_adjustment.py @@ -17,6 +17,8 @@ from app.models.base import Base if TYPE_CHECKING: + from collections.abc import Sequence + from app.core.uow import UnitOfWork @@ -66,5 +68,17 @@ def exists_for_consumption(self, lot_consumption_id: uuid.UUID) -> bool: ) return bool(self.session.execute(statement).scalar_one()) + def list_for_replacement_lots( + self, replacement_tax_lot_ids: Sequence[uuid.UUID] + ) -> list[WashSaleAdjustment]: + """Every disallowance folded into any of these lots' basis, in one query (GET /api/v1/lots). + A lot can receive more than one disallowance over its life; callers sum, never assume ≤1.""" + if not replacement_tax_lot_ids: + return [] + statement = select(WashSaleAdjustment).where( + WashSaleAdjustment.replacement_tax_lot_id.in_(replacement_tax_lot_ids) + ) + return list(self.session.execute(statement).scalars().all()) + __all__ = ["WashSaleAdjustment", "WashSaleAdjustmentRepository"] diff --git a/backend/app/models/marketdata/security.py b/backend/app/models/marketdata/security.py index 1ec89a8..af08c5c 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 +from sqlalchemy import DateTime, String, func, select from sqlalchemy import Enum as SQLAlchemyEnum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -23,6 +23,8 @@ from app.models.ledger._enum import enum_values if TYPE_CHECKING: + from collections.abc import Sequence + from app.core.uow import UnitOfWork @@ -70,3 +72,10 @@ def get_by_id(self, security_id: uuid.UUID) -> Security | None: def get_by_symbol(self, symbol: str) -> Security | None: return self.session.query(Security).filter_by(symbol=symbol).first() + + def list_by_ids(self, security_ids: Sequence[uuid.UUID]) -> list[Security]: + """Batched fetch for a list read that touches many securities at once (GET /api/v1/lots).""" + if not security_ids: + return [] + statement = select(Security).where(Security.id.in_(security_ids)) + return list(self.session.execute(statement).scalars().all()) diff --git a/backend/app/services/lots/lots_summary_service.py b/backend/app/services/lots/lots_summary_service.py new file mode 100644 index 0000000..9bd4c53 --- /dev/null +++ b/backend/app/services/lots/lots_summary_service.py @@ -0,0 +1,117 @@ +"""Assembles a customer's tax-lot read model: every lot ever opened, enriched with current market +value, unrealized gain, wash-sale-disallowed loss, and per-sale consumption history (S8 §3, data +owned by S5). Mirrors FeeSummaryService's shape: a thin dataclass-returning assembly layer, called +by a thin controller that builds the Pydantic view. + +Batches every N+1-shaped lookup (consumptions, securities, wash-sale adjustments) across the whole +lot list. The one per-security loop this service runs (daily closes) mirrors +ValuationService.value_book's own established idiom for "no batch daily-close repository method +exists" -- a customer's distinct held securities is a small, rate-limit-bounded set (GET /lots is +capped at 60 req/min), so this is not the N+1 pattern the batched lookups above are eliminating. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from app.core.money import Money, Units + +if TYPE_CHECKING: + import uuid + from datetime import date + + from app.core.money import Price + from app.models.ledger.lot_consumption import LotConsumption + from app.models.ledger.tax_lot import TaxLot + from app.services.lots.uow import LotsUnitOfWork + + +@dataclass(frozen=True, slots=True) +class LotSummary: + lot: TaxLot + symbol: str + realized_gain_loss: Money + is_provisional: bool + current_price: Price | None + market_value: Money | None + unrealized_gain_loss: Money | None + wash_sale_disallowed: Money + consumptions: list[LotConsumption] + + +@dataclass(frozen=True, slots=True) +class LotsSummary: + lots: list[LotSummary] + + +class LotsSummaryService: + def __init__(self, uow: LotsUnitOfWork) -> None: + self._uow = uow + + def summarize(self, customer_id: uuid.UUID, *, as_of_date: date) -> 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) + lot_ids = [lot.id for lot in lots] + + consumptions_by_lot: dict[uuid.UUID, list[LotConsumption]] = defaultdict(list) + for consumption in self._uow.lot_consumptions.list_for_lots(lot_ids): + consumptions_by_lot[consumption.tax_lot_id].append(consumption) + + securities_by_id = { + s.id: s + for s in self._uow.securities.list_by_ids(list({lot.security_id for lot in lots})) + } + + wash_sale_by_lot: dict[uuid.UUID, Money] = defaultdict(lambda: Money("0.00")) + for adj in self._uow.wash_sale_adjustments.list_for_replacement_lots(lot_ids): + wash_sale_by_lot[adj.replacement_tax_lot_id] = ( + wash_sale_by_lot[adj.replacement_tax_lot_id] + adj.disallowed_amount + ) + + open_security_ids = {lot.security_id for lot in lots if lot.quantity_remaining > Units("0")} + price_by_security: dict[uuid.UUID, Price] = {} + for security_id in open_security_ids: + close = self._uow.daily_closes.latest_confirmed( + security_id=security_id, market_date=as_of_date + ) + if close is not None: + price_by_security[security_id] = close.close_price + + items: list[LotSummary] = [] + 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 = securities_by_id.get(lot.security_id) + current_price = price_by_security.get(lot.security_id) + + market_value: Money | None = None + unrealized_gain_loss: Money | None = None + if current_price is not None and lot.quantity_remaining > Units("0"): + market_value = lot.quantity_remaining * current_price # Units * Price -> Money + unrealized_gain_loss = market_value - lot.adjusted_basis + + items.append( + LotSummary( + lot=lot, + symbol=security.symbol if security is not None else "", + realized_gain_loss=realized_gain_loss, + is_provisional=any(c.is_provisional for c in lot_consumptions), + current_price=current_price, + market_value=market_value, + unrealized_gain_loss=unrealized_gain_loss, + wash_sale_disallowed=wash_sale_by_lot.get(lot.id, Money("0.00")), + consumptions=lot_consumptions, + ) + ) + + return LotsSummary(lots=items) + + +__all__ = ["LotSummary", "LotsSummary", "LotsSummaryService"] diff --git a/backend/app/views/lots.py b/backend/app/views/lots.py index 13e7c17..f899d39 100644 --- a/backend/app/views/lots.py +++ b/backend/app/views/lots.py @@ -13,14 +13,30 @@ from pydantic import BaseModel -from app.core.money import Money, Units # noqa: TC001 +from app.core.money import Money, Price, Units # noqa: TC001 + + +class LotConsumptionResponse(BaseModel): + """One `lot_consumption` row against this lot. `is_provisional` is frozen at sale time and + never re-evaluated later (S5 §3.2).""" + + id: str + sale_date: date + quantity_consumed: Units + realized_gain_loss: Money + is_provisional: bool class LotResponse(BaseModel): """One `tax_lot` row (S5 §3.1), enriched with its own `lot_consumption` rows' aggregated realized gain and provisional status (S5 §7 edge case 1: a lot's consumptions are summed, while each one's provisional flag is preserved for display via `is_provisional` here being - true if *any* of them still is).""" + true if *any* of them still is), plus current market value/unrealized gain and per-sale detail. + + `wash_sale_disallowed` is an ADDITIONAL disclosure -- how much loss was disallowed and folded + into this lot's basis -- never a way to reconstruct the pre-wash-sale-adjustment gain. + `adjusted_basis`/`realized_gain_loss` stay the only adjusted figures ever shown; this module has + no field for the raw pre-adjustment amount (docs/specs/08-surfaces.md §5's explicit rule).""" id: str security_id: str @@ -34,10 +50,15 @@ class LotResponse(BaseModel): acquired_at: date designation: str designation_window_closes_at: datetime + current_price: Price | None + market_value: Money | None + unrealized_gain_loss: Money | None + wash_sale_disallowed: Money + consumptions: list[LotConsumptionResponse] class LotsListResponse(BaseModel): lots: list[LotResponse] -__all__ = ["LotResponse", "LotsListResponse"] +__all__ = ["LotConsumptionResponse", "LotResponse", "LotsListResponse"] diff --git a/backend/tests/api/test_lots.py b/backend/tests/api/test_lots.py index 6f1804d..2e2f742 100644 --- a/backend/tests/api/test_lots.py +++ b/backend/tests/api/test_lots.py @@ -14,11 +14,16 @@ from sqlalchemy import Engine, text from sqlalchemy.orm import Session -from app.core.money import Money, Units +from app.core.clock import InMemoryTradingCalendar, MarketClock +from app.core.money import Money, Price, Units from app.models.identity.staff import Staff, StaffRole +from app.models.ledger.journal_entry import JournalEntry, JournalEntryType from app.models.ledger.lot_consumption import LotConsumption from app.models.ledger.tax_lot import LotDesignation, TaxLot +from app.models.ledger.wash_sale_adjustment import WashSaleAdjustment +from app.models.marketdata.daily_close import DailyClose, DailyCloseStatus, MarketDataSource from app.models.marketdata.security import Security, SecurityAssetClass +from app.models.ops.inbound_event import InboundEvent, InboundEventSource 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.identity.auth import hash_password @@ -60,10 +65,14 @@ def _staff_login_with_mfa(client: FlaskClient) -> None: LOTS_TABLES = [ Security.__table__, + InboundEvent.__table__, + JournalEntry.__table__, + DailyClose.__table__, Order.__table__, OrderEvent.__table__, TaxLot.__table__, LotConsumption.__table__, + WashSaleAdjustment.__table__, ] @@ -115,6 +124,130 @@ def _seed_fill_order_event( session.flush() +def _today_market_date() -> date: + """Resolves "today" through the same UTC -> America/New_York path `MarketClock` gives the + controller, so seeded `DailyClose` rows never go stale/flaky.""" + return MarketClock(InMemoryTradingCalendar()).market_date(datetime.now(UTC)) + + +def _seed_security(session: Session, *, symbol: str) -> uuid.UUID: + security = Security(symbol=symbol, name=f"{symbol} Co.", asset_class=SecurityAssetClass.EQUITY) + session.add(security) + session.flush() + return security.id + + +def _seed_daily_close( + session: Session, *, security_id: uuid.UUID, market_date: date, close_price: Price +) -> None: + session.add( + DailyClose( + security_id=security_id, + market_date=market_date, + close_price=close_price, + source=MarketDataSource.SIMULATED, + status=DailyCloseStatus.CONFIRMED, + ) + ) + session.flush() + + +def _seed_open_lot( + session: Session, + *, + customer_id: uuid.UUID, + security_id: uuid.UUID, + execution_id: str, + quantity_opened: Units, + quantity_remaining: Units, + original_cost_basis: Money, + adjusted_basis: Money, + acquired_at: date = date(2026, 1, 5), +) -> TaxLot: + _seed_fill_order_event( + session, customer_id=customer_id, security_id=security_id, execution_id=execution_id + ) + lot = TaxLot( + customer_id=customer_id, + security_id=security_id, + opening_fill_execution_id=execution_id, + quantity_opened=quantity_opened, + quantity_remaining=quantity_remaining, + original_cost_basis=original_cost_basis, + adjusted_basis=adjusted_basis, + acquired_at=acquired_at, + designation=LotDesignation.UNSPECIFIED, + designation_window_closes_at=datetime(2026, 1, 8, 16, 0, tzinfo=UTC), + ) + session.add(lot) + session.flush() + return lot + + +def _seed_lot_consumption( + session: Session, + *, + customer_id: uuid.UUID, + security_id: uuid.UUID, + tax_lot_id: uuid.UUID, + execution_id: str, + quantity_consumed: Units, + realized_gain_loss: Money, + sale_date: date, + is_provisional: bool = False, +) -> LotConsumption: + _seed_fill_order_event( + session, customer_id=customer_id, security_id=security_id, execution_id=execution_id + ) + consumption = LotConsumption( + closing_fill_execution_id=execution_id, + tax_lot_id=tax_lot_id, + quantity_consumed=quantity_consumed, + realized_gain_loss=realized_gain_loss, + is_provisional=is_provisional, + sale_date=sale_date, + ) + session.add(consumption) + session.flush() + return consumption + + +def _seed_wash_sale_adjustment( + session: Session, + *, + original_lot_consumption_id: uuid.UUID, + replacement_tax_lot_id: uuid.UUID, + disallowed_amount: Money, + effective_date: date, +) -> None: + inbound_event = InboundEvent( + source=InboundEventSource.ALPACA, + source_event_id=str(uuid.uuid4()), + payload={}, + signature_verified=True, + ) + session.add(inbound_event) + session.flush() + + journal_entry = JournalEntry( + entry_type=JournalEntryType.WASH_SALE_ADJUSTMENT, + effective_date=effective_date, + source_event_id=inbound_event.id, + ) + session.add(journal_entry) + session.flush() + + session.add( + WashSaleAdjustment( + original_lot_consumption_id=original_lot_consumption_id, + replacement_tax_lot_id=replacement_tax_lot_id, + disallowed_amount=disallowed_amount, + journal_entry_id=journal_entry.id, + ) + ) + session.flush() + + def _seed_lot_with_wash_sale_and_provisional_sale( owner_engine: Engine, customer_id: uuid.UUID ) -> tuple[uuid.UUID, str]: @@ -194,6 +327,19 @@ def test_customer_sees_own_lots_with_adjusted_basis_and_provisional_flag( assert lot["realized_gain_loss"] == "-50.0000" assert lot["is_provisional"] is True assert lot["designation"] == "unspecified" + # No wash-sale adjustment was ever recorded against this lot. + assert lot["wash_sale_disallowed"] == "0.0000" + # No DailyClose row exists for WASH -- open lot, but no confirmed price to mark it against. + assert lot["current_price"] is None + assert lot["market_value"] is None + assert lot["unrealized_gain_loss"] is None + # The seeded provisional sell fill, in full (S8 §3's per-sale detail). + assert len(lot["consumptions"]) == 1 + consumption = lot["consumptions"][0] + assert consumption["quantity_consumed"] == "5.000000" + assert consumption["realized_gain_loss"] == "-50.0000" + assert consumption["is_provisional"] is True + assert consumption["sale_date"] == "2026-01-06" def test_a_customer_never_sees_another_customers_lots( @@ -232,6 +378,244 @@ def test_staff_sees_a_named_customers_lots( assert len(response.get_json()["lots"]) == 1 +def test_lot_with_no_confirmed_close_has_null_market_value( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id, _ = _register_and_login(api_client) + session = Session(bind=owner_engine, expire_on_commit=False) + try: + security_id = _seed_security(session, symbol="NOCLOSE") + lot = _seed_open_lot( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + execution_id="buy-noclose", + quantity_opened=Units("10"), + quantity_remaining=Units("10"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.00"), + ) + session.commit() + finally: + session.close() + + response = api_client.get("/api/v1/lots") + + assert response.status_code == 200 + lots = response.get_json()["lots"] + assert len(lots) == 1 + assert lots[0]["id"] == str(lot.id) + assert lots[0]["current_price"] is None + assert lots[0]["market_value"] is None + assert lots[0]["unrealized_gain_loss"] is None + + +def test_lot_with_confirmed_close_has_market_value_and_unrealized_gain( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id, _ = _register_and_login(api_client) + today = _today_market_date() + session = Session(bind=owner_engine, expire_on_commit=False) + try: + security_id = _seed_security(session, symbol="MKTVAL") + lot = _seed_open_lot( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + execution_id="buy-mktval", + quantity_opened=Units("10"), + quantity_remaining=Units("10"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.00"), + ) + _seed_daily_close( + session, security_id=security_id, market_date=today, close_price=Price("110.00") + ) + session.commit() + finally: + session.close() + + response = api_client.get("/api/v1/lots") + + assert response.status_code == 200 + lots = response.get_json()["lots"] + assert len(lots) == 1 + lot_json = lots[0] + assert lot_json["id"] == str(lot.id) + # 10 units x $110.000000 close = $1100.0000 market value, minus $1000.0000 basis = $100.0000. + assert lot_json["current_price"] == "110.000000" + assert lot_json["market_value"] == "1100.0000" + assert lot_json["unrealized_gain_loss"] == "100.0000" + + +def test_closed_lot_has_null_market_value_even_with_a_confirmed_close( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id, _ = _register_and_login(api_client) + today = _today_market_date() + session = Session(bind=owner_engine, expire_on_commit=False) + try: + security_id = _seed_security(session, symbol="CLOSEDLOT") + lot = _seed_open_lot( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + execution_id="buy-closed", + quantity_opened=Units("10"), + quantity_remaining=Units("0"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.00"), + ) + _seed_daily_close( + session, security_id=security_id, market_date=today, close_price=Price("110.00") + ) + session.commit() + finally: + session.close() + + response = api_client.get("/api/v1/lots") + + assert response.status_code == 200 + lots = response.get_json()["lots"] + assert len(lots) == 1 + lot_json = lots[0] + assert lot_json["id"] == str(lot.id) + assert lot_json["current_price"] is None + assert lot_json["market_value"] is None + assert lot_json["unrealized_gain_loss"] is None + + +def test_lot_wash_sale_disallowed_sums_multiple_adjustments( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id, _ = _register_and_login(api_client) + session = Session(bind=owner_engine, expire_on_commit=False) + try: + security_id = _seed_security(session, symbol="WASHSUM") + replacement_lot = _seed_open_lot( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + execution_id="buy-replacement", + quantity_opened=Units("10"), + quantity_remaining=Units("10"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1050.00"), + ) + sold_lot_1 = _seed_open_lot( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + execution_id="buy-sold-1", + quantity_opened=Units("5"), + quantity_remaining=Units("0"), + original_cost_basis=Money("500.00"), + adjusted_basis=Money("500.00"), + ) + sold_lot_2 = _seed_open_lot( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + execution_id="buy-sold-2", + quantity_opened=Units("5"), + quantity_remaining=Units("0"), + original_cost_basis=Money("500.00"), + adjusted_basis=Money("500.00"), + ) + consumption_1 = _seed_lot_consumption( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + tax_lot_id=sold_lot_1.id, + execution_id="sell-1", + quantity_consumed=Units("5"), + realized_gain_loss=Money("-30.00"), + sale_date=date(2026, 1, 4), + ) + consumption_2 = _seed_lot_consumption( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + tax_lot_id=sold_lot_2.id, + execution_id="sell-2", + quantity_consumed=Units("5"), + realized_gain_loss=Money("-20.00"), + sale_date=date(2026, 1, 4), + ) + _seed_wash_sale_adjustment( + session, + original_lot_consumption_id=consumption_1.id, + replacement_tax_lot_id=replacement_lot.id, + disallowed_amount=Money("30.00"), + effective_date=date(2026, 1, 5), + ) + _seed_wash_sale_adjustment( + session, + original_lot_consumption_id=consumption_2.id, + replacement_tax_lot_id=replacement_lot.id, + disallowed_amount=Money("20.00"), + effective_date=date(2026, 1, 5), + ) + session.commit() + finally: + session.close() + + response = api_client.get("/api/v1/lots") + + assert response.status_code == 200 + lots = {entry["id"]: entry for entry in response.get_json()["lots"]} + assert lots[str(replacement_lot.id)]["wash_sale_disallowed"] == "50.0000" + + +def test_lot_consumptions_ordered_most_recent_sale_first( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id, _ = _register_and_login(api_client) + session = Session(bind=owner_engine, expire_on_commit=False) + try: + security_id = _seed_security(session, symbol="ORDERED") + lot = _seed_open_lot( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + execution_id="buy-ordered", + quantity_opened=Units("10"), + quantity_remaining=Units("4"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.00"), + ) + earlier = _seed_lot_consumption( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + tax_lot_id=lot.id, + execution_id="sell-earlier", + quantity_consumed=Units("3"), + realized_gain_loss=Money("10.00"), + sale_date=date(2026, 1, 6), + ) + later = _seed_lot_consumption( + session, + customer_id=uuid.UUID(customer_id), + security_id=security_id, + tax_lot_id=lot.id, + execution_id="sell-later", + quantity_consumed=Units("3"), + realized_gain_loss=Money("20.00"), + sale_date=date(2026, 1, 10), + ) + session.commit() + finally: + session.close() + + response = api_client.get("/api/v1/lots") + + assert response.status_code == 200 + lots = {entry["id"]: entry for entry in response.get_json()["lots"]} + consumption_ids = [c["id"] for c in lots[str(lot.id)]["consumptions"]] + assert consumption_ids == [str(later.id), str(earlier.id)] + + def test_is_throttled(api_client: FlaskClient) -> None: _register_and_login(api_client) diff --git a/backend/tests/integration/test_lots_summary_service.py b/backend/tests/integration/test_lots_summary_service.py new file mode 100644 index 0000000..0133b8d --- /dev/null +++ b/backend/tests/integration/test_lots_summary_service.py @@ -0,0 +1,358 @@ +"""`LotsSummaryService` against real Postgres (S8 §3, data owned by S5): market value/unrealized +gain arithmetic for an open lot, the closed-lot-always-null rule, and wash-sale-disallowed +summation across multiple adjustments on one lot.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, date, datetime + +import pytest +from sqlalchemy import text + +from app.core.db import DbRole +from app.core.money import Money, Price, Units +from app.core.uow import SessionRole +from app.models.ledger.journal_entry import JournalEntry, JournalEntryType +from app.models.ledger.lot_consumption import LotConsumption +from app.models.ledger.tax_lot import LotDesignation, TaxLot +from app.models.ledger.wash_sale_adjustment import WashSaleAdjustment +from app.models.marketdata.daily_close import DailyClose, DailyCloseStatus, MarketDataSource +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.lots_summary_service import LotsSummaryService +from app.services.lots.uow import LotsUnitOfWork +from tests.integration.conftest import LEDGER_TABLES, insert_customer, insert_inbound_event + +LOTS_SUMMARY_TABLES = [ + *LEDGER_TABLES, + Security.__table__, + DailyClose.__table__, + Order.__table__, + OrderEvent.__table__, + TaxLot.__table__, + LotConsumption.__table__, + WashSaleAdjustment.__table__, +] + +AS_OF_DATE = date(2026, 1, 15) + + +@pytest.fixture +def lots_summary_tables(owner_engine): + for table in LOTS_SUMMARY_TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield + with owner_engine.begin() as connection: + for table in reversed(LOTS_SUMMARY_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE')) + + +pytestmark = pytest.mark.usefixtures("lots_summary_tables") + + +def _owner_uow() -> LotsUnitOfWork: + return LotsUnitOfWork(customer_id=None, role=SessionRole.ADMIN, db_role=DbRole.OWNER) + + +def _insert_security(uow: LotsUnitOfWork, *, symbol: str) -> uuid.UUID: + security = Security(symbol=symbol, name=f"{symbol} Co", asset_class=SecurityAssetClass.EQUITY) + uow.session.add(security) + uow.session.flush() + return security.id + + +def _insert_fill_order_event( + uow: LotsUnitOfWork, *, customer_id: uuid.UUID, security_id: uuid.UUID, execution_id: str +) -> None: + """Standing in for the order/order_event row a real fill creates -- `tax_lot`/`lot_consumption` + both FK onto `order_event.execution_id`.""" + order = Order( + 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(uuid.uuid4()), + ) + uow.session.add(order) + uow.session.flush() + uow.session.add( + OrderEvent( + order_id=order.id, seq=1, event_type=OrderEventType.FILL, + execution_id=execution_id, payload={}, + ) + ) + uow.session.flush() + + +def _insert_lot( + uow: LotsUnitOfWork, + *, + customer_id: uuid.UUID, + security_id: uuid.UUID, + execution_id: str, + quantity_opened: Units, + quantity_remaining: Units, + original_cost_basis: Money, + adjusted_basis: Money, +) -> TaxLot: + _insert_fill_order_event( + uow, customer_id=customer_id, security_id=security_id, execution_id=execution_id + ) + lot = TaxLot( + customer_id=customer_id, + security_id=security_id, + opening_fill_execution_id=execution_id, + quantity_opened=quantity_opened, + quantity_remaining=quantity_remaining, + original_cost_basis=original_cost_basis, + adjusted_basis=adjusted_basis, + acquired_at=date(2026, 1, 5), + designation=LotDesignation.UNSPECIFIED, + designation_window_closes_at=datetime(2026, 1, 8, 16, 0, tzinfo=UTC), + ) + uow.session.add(lot) + uow.session.flush() + return lot + + +def _insert_consumption( + uow: LotsUnitOfWork, + *, + customer_id: uuid.UUID, + security_id: uuid.UUID, + tax_lot_id: uuid.UUID, + execution_id: str, + quantity_consumed: Units, + realized_gain_loss: Money, + sale_date: date, +) -> LotConsumption: + _insert_fill_order_event( + uow, customer_id=customer_id, security_id=security_id, execution_id=execution_id + ) + consumption = LotConsumption( + closing_fill_execution_id=execution_id, + tax_lot_id=tax_lot_id, + quantity_consumed=quantity_consumed, + realized_gain_loss=realized_gain_loss, + is_provisional=False, + sale_date=sale_date, + ) + uow.session.add(consumption) + uow.session.flush() + return consumption + + +def _insert_daily_close( + uow: LotsUnitOfWork, *, security_id: uuid.UUID, market_date: date, close_price: Price +) -> None: + uow.session.add( + DailyClose( + security_id=security_id, + market_date=market_date, + close_price=close_price, + source=MarketDataSource.SIMULATED, + status=DailyCloseStatus.CONFIRMED, + ) + ) + uow.session.flush() + + +def _insert_wash_sale_adjustment( + uow: LotsUnitOfWork, + *, + original_lot_consumption_id: uuid.UUID, + replacement_tax_lot_id: uuid.UUID, + disallowed_amount: Money, +) -> None: + inbound_event_id = insert_inbound_event(uow.session) + journal_entry = JournalEntry( + entry_type=JournalEntryType.WASH_SALE_ADJUSTMENT, + effective_date=date(2026, 1, 5), + source_event_id=inbound_event_id, + ) + uow.session.add(journal_entry) + uow.session.flush() + uow.session.add( + WashSaleAdjustment( + original_lot_consumption_id=original_lot_consumption_id, + replacement_tax_lot_id=replacement_tax_lot_id, + disallowed_amount=disallowed_amount, + journal_entry_id=journal_entry.id, + ) + ) + uow.session.flush() + + +# --- market value / unrealized gain -------------------------------------------------------- + + +def test_open_lot_gets_market_value_and_unrealized_gain_from_confirmed_close() -> None: + with _owner_uow() as uow: + customer_id = insert_customer(uow.session) + security_id = _insert_security(uow, symbol=f"OPEN{uuid.uuid4().hex[:6]}") + lot = _insert_lot( + uow, + customer_id=customer_id, + security_id=security_id, + execution_id="buy-open", + quantity_opened=Units("10"), + quantity_remaining=Units("10"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.00"), + ) + _insert_daily_close( + uow, security_id=security_id, market_date=AS_OF_DATE, close_price=Price("110.00") + ) + lot_id = lot.id + uow.commit() + + with _owner_uow() as uow: + summary = LotsSummaryService(uow).summarize(customer_id, as_of_date=AS_OF_DATE) + items = {item.lot.id: item for item in summary.lots} + item = items[lot_id] + assert item.current_price == Price("110.00") + # 10 units x $110.00 = $1100.00 market value; minus $1000.00 basis = $100.00 gain. + assert item.market_value == Money("1100.00") + assert item.unrealized_gain_loss == Money("100.00") + + +def test_lot_with_no_confirmed_close_has_no_market_value() -> None: + with _owner_uow() as uow: + customer_id = insert_customer(uow.session) + security_id = _insert_security(uow, symbol=f"NOPRICE{uuid.uuid4().hex[:6]}") + lot = _insert_lot( + uow, + customer_id=customer_id, + security_id=security_id, + execution_id="buy-noprice", + quantity_opened=Units("10"), + quantity_remaining=Units("10"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.00"), + ) + lot_id = lot.id + uow.commit() + + with _owner_uow() as uow: + summary = LotsSummaryService(uow).summarize(customer_id, as_of_date=AS_OF_DATE) + items = {item.lot.id: item for item in summary.lots} + item = items[lot_id] + assert item.current_price is None + assert item.market_value is None + assert item.unrealized_gain_loss is None + + +def test_closed_lot_market_value_and_unrealized_gain_are_always_none() -> None: + """S8 §3: a fully-consumed lot has no remaining position to mark to market, even when a + confirmed close exists for its security.""" + with _owner_uow() as uow: + customer_id = insert_customer(uow.session) + security_id = _insert_security(uow, symbol=f"CLOSED{uuid.uuid4().hex[:6]}") + lot = _insert_lot( + uow, + customer_id=customer_id, + security_id=security_id, + execution_id="buy-closed", + quantity_opened=Units("10"), + quantity_remaining=Units("0"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.00"), + ) + _insert_daily_close( + uow, security_id=security_id, market_date=AS_OF_DATE, close_price=Price("110.00") + ) + lot_id = lot.id + uow.commit() + + with _owner_uow() as uow: + summary = LotsSummaryService(uow).summarize(customer_id, as_of_date=AS_OF_DATE) + items = {item.lot.id: item for item in summary.lots} + item = items[lot_id] + assert item.market_value is None + assert item.unrealized_gain_loss is None + + +# --- wash-sale-disallowed summation --------------------------------------------------------- + + +def test_wash_sale_disallowed_sums_multiple_adjustments_on_the_same_lot() -> None: + with _owner_uow() as uow: + customer_id = insert_customer(uow.session) + security_id = _insert_security(uow, symbol=f"WASH{uuid.uuid4().hex[:6]}") + + replacement_lot = _insert_lot( + uow, + customer_id=customer_id, + security_id=security_id, + execution_id="buy-replacement", + quantity_opened=Units("10"), + quantity_remaining=Units("10"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1050.00"), + ) + sold_lot_1 = _insert_lot( + uow, + customer_id=customer_id, + security_id=security_id, + execution_id="buy-sold-1", + quantity_opened=Units("5"), + quantity_remaining=Units("0"), + original_cost_basis=Money("500.00"), + adjusted_basis=Money("500.00"), + ) + sold_lot_2 = _insert_lot( + uow, + customer_id=customer_id, + security_id=security_id, + execution_id="buy-sold-2", + quantity_opened=Units("5"), + quantity_remaining=Units("0"), + original_cost_basis=Money("500.00"), + adjusted_basis=Money("500.00"), + ) + consumption_1 = _insert_consumption( + uow, + customer_id=customer_id, + security_id=security_id, + tax_lot_id=sold_lot_1.id, + execution_id="sell-1", + quantity_consumed=Units("5"), + realized_gain_loss=Money("-30.00"), + sale_date=date(2026, 1, 4), + ) + consumption_2 = _insert_consumption( + uow, + customer_id=customer_id, + security_id=security_id, + tax_lot_id=sold_lot_2.id, + execution_id="sell-2", + quantity_consumed=Units("5"), + realized_gain_loss=Money("-20.00"), + sale_date=date(2026, 1, 4), + ) + _insert_wash_sale_adjustment( + uow, + original_lot_consumption_id=consumption_1.id, + replacement_tax_lot_id=replacement_lot.id, + disallowed_amount=Money("30.00"), + ) + _insert_wash_sale_adjustment( + uow, + original_lot_consumption_id=consumption_2.id, + replacement_tax_lot_id=replacement_lot.id, + disallowed_amount=Money("20.00"), + ) + replacement_lot_id = replacement_lot.id + sold_lot_1_id = sold_lot_1.id + uow.commit() + + with _owner_uow() as uow: + summary = LotsSummaryService(uow).summarize(customer_id, as_of_date=AS_OF_DATE) + items = {item.lot.id: item for item in summary.lots} + assert items[replacement_lot_id].wash_sale_disallowed == Money("50.00") + # Lots with no adjustment against them stay at zero, not missing. + assert items[sold_lot_1_id].wash_sale_disallowed == Money("0.00") diff --git a/frontend/src/components/Table/Table.css b/frontend/src/components/Table/Table.css index 4f4bba6..8cd23dd 100644 --- a/frontend/src/components/Table/Table.css +++ b/frontend/src/components/Table/Table.css @@ -5,6 +5,11 @@ .tu-table { width: 100%; + /* Lets the table grow past 100% to its natural (nowrap) width when the container is too narrow + -- otherwise `width: 100%` alone caps it at the container width, cells wrap instead, and + .tu-table-wrapper's overflow-x: auto never has anything to scroll. design-system.md §5: + "tables gain horizontal scroll inside their own container, never reflow into cards." */ + min-width: max-content; border-collapse: collapse; font-family: var(--font-sans); } @@ -41,6 +46,9 @@ font-size: var(--font-size-data); line-height: var(--line-height-data); font-variant-numeric: normal; + /* Matches the header cells -- a data cell that wraps defeats min-width: max-content above and + produces the same broken-reflow layout it's meant to prevent. */ + white-space: nowrap; } .tu-table tbody tr { diff --git a/frontend/src/features/lots/api/lotsApi.js b/frontend/src/features/lots/api/lotsApi.js index 03636bf..5eba4f1 100644 --- a/frontend/src/features/lots/api/lotsApi.js +++ b/frontend/src/features/lots/api/lotsApi.js @@ -1,55 +1,12 @@ -// MOCK -- the real backend route (GET /lots) exists and is tested -// (backend/app/controllers/api/lots.py), the frontend just hasn't been wired to it yet. See -// docs/superpowers/specs/2026-09-05-frontend-completion-design.md. -import { mockClient } from '../../../services/mockClient.js' - -const NAMESPACE = 'lots' - -function seed() { - return { - lots: [ - { - id: 'lot-1', - security_id: 'sec-vti', - symbol: 'VTI', - quantity_opened: '10.000000', - quantity_remaining: '10.000000', - original_cost_basis: '2400.00', - adjusted_basis: '2400.00', - acquired_at: '2026-06-15', - is_provisional: false, - // No quote endpoint yet; current_price is mock data local to this file. - current_price: '265.40', - }, - { - id: 'lot-2', - security_id: 'sec-bnd', - symbol: 'BND', - quantity_opened: '15.000000', - quantity_remaining: '15.000000', - original_cost_basis: '1080.00', - adjusted_basis: '1150.00', - acquired_at: '2026-07-02', - is_provisional: true, - wash_sale_note: 'A prior loss sale on BND was disallowed and added to this lot’s basis (ADR 11).', - current_price: '72.85', - }, - { - id: 'lot-3', - security_id: 'sec-vxus', - symbol: 'VXUS', - quantity_opened: '8.000000', - quantity_remaining: '8.000000', - original_cost_basis: '448.00', - adjusted_basis: '448.00', - acquired_at: '2026-08-11', - is_provisional: false, - current_price: '58.20', - }, - ], - } -} +// 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. +import { apiClient } from '../../../services/apiClient.js' export const lotsApi = { - list: () => mockClient.request(mockClient.getStore(NAMESPACE, seed).lots), + list: async () => { + const data = await apiClient.get('/lots') + return data.lots + }, } diff --git a/frontend/src/features/lots/components/ConsumptionHistory.css b/frontend/src/features/lots/components/ConsumptionHistory.css new file mode 100644 index 0000000..d65ce53 --- /dev/null +++ b/frontend/src/features/lots/components/ConsumptionHistory.css @@ -0,0 +1,15 @@ +.tu-consumption-history__empty { + margin: 0; + font-size: var(--font-size-caption); + color: var(--color-text-secondary); +} + +/* Gains/losses in tables use success/error text color on the figure itself, not a badge + (design-system §2.4) — same convention as the top-level lot table's unrealized column. */ +.tu-consumption-history__gain { + color: var(--color-success); +} + +.tu-consumption-history__loss { + color: var(--color-error); +} diff --git a/frontend/src/features/lots/components/ConsumptionHistory.jsx b/frontend/src/features/lots/components/ConsumptionHistory.jsx new file mode 100644 index 0000000..6299841 --- /dev/null +++ b/frontend/src/features/lots/components/ConsumptionHistory.jsx @@ -0,0 +1,45 @@ +import Decimal from 'decimal.js' +import { Badge } from '../../../components/Badge' +import { Table } from '../../../components/Table' +import { UnitsValue } from '../../../components/UnitsValue' +import { formatDate, formatMoney } from '../../../utils/format.js' +import './ConsumptionHistory.css' + +/** One lot's `consumptions` array — its individual sale history, already sorted most-recent-first + * by the backend. Each row's `is_provisional` is frozen at sale time and never re-evaluated later, + * so it's shown as-is per row rather than reconciled against the lot's own current flag. */ +export function ConsumptionHistory({ consumptions }) { + if (consumptions.length === 0) { + return

No sales from this lot yet.

+ } + + return ( + + + Sale date + Quantity sold + Realized gain/loss +   + + + {consumptions.map((consumption) => { + const gainLoss = new Decimal(consumption.realized_gain_loss) + return ( + + {formatDate(consumption.sale_date)} + + + + + + {formatMoney(gainLoss)} + + + {consumption.is_provisional && Provisional} + + ) + })} + +
+ ) +} diff --git a/frontend/src/features/lots/components/HoldingPeriodBadge.css b/frontend/src/features/lots/components/HoldingPeriodBadge.css new file mode 100644 index 0000000..eb8410e --- /dev/null +++ b/frontend/src/features/lots/components/HoldingPeriodBadge.css @@ -0,0 +1,18 @@ +.tu-holding-period-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + flex-wrap: wrap; +} + +/* Never color alone (design-system §2.4): the icon plus the "may differ" wording both carry the + qualification, in warning tone since it's a genuine caveat on an otherwise-plain classification. */ +.tu-holding-period-badge__qualifier { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + color: var(--color-warning); + white-space: nowrap; +} diff --git a/frontend/src/features/lots/components/HoldingPeriodBadge.jsx b/frontend/src/features/lots/components/HoldingPeriodBadge.jsx new file mode 100644 index 0000000..a0c81b1 --- /dev/null +++ b/frontend/src/features/lots/components/HoldingPeriodBadge.jsx @@ -0,0 +1,32 @@ +import { Badge } from '../../../components/Badge' +import { Icon } from '../../../components/Icon' +import { hasWashSaleDisallowed, holdingPeriod } from '../utils/lotSummary.js' +import './HoldingPeriodBadge.css' + +const LABEL_BY_TERM = { 'long-term': 'Long-term', 'short-term': 'Short-term' } + +/** + * Only ever rendered by a caller for a still-open lot (design brief item 5) — no reliable per-lot + * sale date exists at the granularity this scope needs to classify a closed lot, so this component + * assumes `lot` is open/partial rather than re-deriving that itself. Neutral tone like + * `LotStatusBadge` (this is a classification, not an alert) — but this backend doesn't model the + * IRS rule that a wash-sale replacement lot inherits its original lot's holding period, so a lot + * with a wash-sale adjustment gets a visible qualifier rather than presenting unqualified + * certainty. + */ +export function HoldingPeriodBadge({ lot }) { + const term = holdingPeriod(lot.acquired_at) + const qualified = hasWashSaleDisallowed(lot) + + return ( + + {LABEL_BY_TERM[term]} + {qualified && ( + + + May differ — wash sale + + )} + + ) +} diff --git a/frontend/src/features/lots/components/LotDetail.css b/frontend/src/features/lots/components/LotDetail.css index e10b858..943268a 100644 --- a/frontend/src/features/lots/components/LotDetail.css +++ b/frontend/src/features/lots/components/LotDetail.css @@ -1,9 +1,27 @@ .tu-lot-detail { display: flex; flex-direction: column; - gap: var(--space-2); - padding: var(--space-3) var(--space-4); + gap: var(--space-4); + padding: var(--space-4); background: var(--color-surface-sunken); + /* This expand row sits inside a `` that inherits Table.css's deliberate + `white-space: nowrap` (the horizontal-scroll-not-reflow fix, Table.css's own comment) — that + rule is scoped to the outer table's own data cells, not to prose nested inside an expanded + detail row, so it's reset back to normal wrapping here rather than touched at the source. */ + white-space: normal; +} + +.tu-lot-detail__wash-sale-note { + margin: 0; + max-width: var(--reading-max-width); font-size: var(--font-size-caption); + line-height: var(--line-height-caption); color: var(--color-text-secondary); } + +.tu-lot-detail__consumptions-title { + margin: 0 0 var(--space-2); + font-size: var(--font-size-body); + font-weight: var(--font-weight-body-medium); + color: var(--color-text); +} diff --git a/frontend/src/features/lots/components/LotDetail.jsx b/frontend/src/features/lots/components/LotDetail.jsx index e7ee29a..5c6de35 100644 --- a/frontend/src/features/lots/components/LotDetail.jsx +++ b/frontend/src/features/lots/components/LotDetail.jsx @@ -1,17 +1,40 @@ +import Decimal from 'decimal.js' +import { DetailFields } from '../../../components/DetailFields' import { formatDate, formatMoney } from '../../../utils/format.js' +import { hasWashSaleDisallowed } from '../utils/lotSummary.js' +import { ConsumptionHistory } from './ConsumptionHistory.jsx' import './LotDetail.css' -/** Design system §7.6/structure.md §6: shows the wash-sale adjustment, not the raw gain. */ +/** Design system §7.6/structure.md §6: shows the wash-sale adjustment as an additional disclosure, + * never the raw pre-adjustment gain — `adjusted_basis`/`realized_gain_loss` stay the only adjusted + * figures ever shown; this component must never compute `adjusted_basis - wash_sale_disallowed` + * and present that as "the real basis." Basis-changed comparison goes through decimal.js `.equals()` + * — money is a wire string, never compared with raw `!==`. */ export function LotDetail({ lot }) { + const basisWasAdjusted = !new Decimal(lot.original_cost_basis).equals(lot.adjusted_basis) + const washSaleDisallowed = hasWashSaleDisallowed(lot) + + const fields = [ + { label: 'Acquired', value: formatDate(lot.acquired_at) }, + { label: 'Original cost basis', value: formatMoney(lot.original_cost_basis) }, + ...(basisWasAdjusted ? [{ label: 'Adjusted basis', value: formatMoney(lot.adjusted_basis) }] : []), + { label: 'Realized gain/loss', value: formatMoney(lot.realized_gain_loss) }, + ...(washSaleDisallowed ? [{ label: 'Wash sale disallowed', value: formatMoney(lot.wash_sale_disallowed) }] : []), + ] + return (
- Opened {formatDate(lot.acquired_at)} - {lot.original_cost_basis !== lot.adjusted_basis && ( - - Original cost basis {formatMoney(lot.original_cost_basis)}, adjusted to {formatMoney(lot.adjusted_basis)} - + + {washSaleDisallowed && ( +

+ A wash sale rule disallowed some loss on this lot; the disallowed amount above was folded + into the adjusted basis already shown — not a separate figure to add or subtract yourself. +

)} - {lot.is_provisional && lot.wash_sale_note && {lot.wash_sale_note}} +
+

Sale history

+ +
) } diff --git a/frontend/src/features/lots/components/LotFilterToolbar.css b/frontend/src/features/lots/components/LotFilterToolbar.css new file mode 100644 index 0000000..3bbcc22 --- /dev/null +++ b/frontend/src/features/lots/components/LotFilterToolbar.css @@ -0,0 +1,8 @@ +.tu-lot-filter-toolbar { + display: flex; + align-items: flex-end; +} + +.tu-lot-filter-toolbar__field { + width: 200px; +} diff --git a/frontend/src/features/lots/components/LotFilterToolbar.jsx b/frontend/src/features/lots/components/LotFilterToolbar.jsx new file mode 100644 index 0000000..03491ce --- /dev/null +++ b/frontend/src/features/lots/components/LotFilterToolbar.jsx @@ -0,0 +1,30 @@ +import { Select } from '../../../components/Select' +import './LotFilterToolbar.css' + +const STATUS_OPTIONS = [ + { value: 'all', label: 'All lots' }, + { value: 'open', label: 'Open' }, + { value: 'partial', label: 'Partially sold' }, + { value: 'closed', label: 'Closed' }, +] + +/** All client-side (no server-side filter params exist for `GET /lots`) — a status filter is the + * only filter this redesign scopes in. */ +export function LotFilterToolbar({ statusFilter, onStatusFilterChange }) { + return ( +
+ +
+ ) +} diff --git a/frontend/src/features/lots/components/LotStatusBadge.jsx b/frontend/src/features/lots/components/LotStatusBadge.jsx new file mode 100644 index 0000000..4f7ab2f --- /dev/null +++ b/frontend/src/features/lots/components/LotStatusBadge.jsx @@ -0,0 +1,11 @@ +import { Badge } from '../../../components/Badge' + +const LABEL_BY_STATUS = { open: 'Open', partial: 'Partially sold', closed: 'Closed' } + +/** Status is derived client-side from quantity_remaining vs. quantity_opened (lotSummary.js's + * `lotStatus`) — an informational disclosure like ProvisionalBadge/SimulatedBadge, not a health + * signal (design-system §8.8's "disclosure, not a warning" precedent), so it stays neutral tone + * regardless of which status it is. */ +export function LotStatusBadge({ status }) { + return {LABEL_BY_STATUS[status] ?? status} +} diff --git a/frontend/src/features/lots/components/LotSummary.css b/frontend/src/features/lots/components/LotSummary.css new file mode 100644 index 0000000..d34b66d --- /dev/null +++ b/frontend/src/features/lots/components/LotSummary.css @@ -0,0 +1,72 @@ +.tu-lot-summary { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.tu-lot-summary__hero { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.tu-lot-summary__label, +.tu-lot-summary__stat-label { + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + color: var(--color-text-secondary); +} + +/* The one serif hero figure on this screen (design-system §3.1) — matching AccrualSummary.css's + `.tu-accrual-summary__value` treatment exactly; the two never co-occur on the same route. */ +.tu-lot-summary__value { + font-family: var(--font-serif); + font-size: var(--font-size-display); + line-height: var(--line-height-display); + font-weight: var(--font-weight-display); + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +.tu-lot-summary__note { + margin: 0; + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + color: var(--color-warning); +} + +.tu-lot-summary__columns { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--space-4); +} + +@media (min-width: 480px) { + .tu-lot-summary__columns { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.tu-lot-summary__columns .tu-card { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* A money/gain figure — never text-muted (design-system §2.3) — sized between body and the serif + hero, gains/losses colored on the figure itself per §2.4. */ +.tu-lot-summary__stat-value { + font-size: var(--font-size-heading-sm); + line-height: var(--line-height-heading-sm); + font-weight: var(--font-weight-heading-sm); + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +.tu-lot-summary__stat-value--gain { + color: var(--color-success); +} + +.tu-lot-summary__stat-value--loss { + color: var(--color-error); +} diff --git a/frontend/src/features/lots/components/LotSummary.jsx b/frontend/src/features/lots/components/LotSummary.jsx new file mode 100644 index 0000000..dcba0d7 --- /dev/null +++ b/frontend/src/features/lots/components/LotSummary.jsx @@ -0,0 +1,52 @@ +import { Card } from '../../../components/Card' +import { formatMoney } from '../../../utils/format.js' +import { hasMissingPrices, totalMarketValue, totalUnrealizedGainLoss, totalWashSaleDisallowed } from '../utils/lotSummary.js' +import './LotSummary.css' + +/** Hero-plus-supporting-cards shape (AccrualSummary/HighWaterMarkCard's precedent from the fees + * redesign). Total market value is the one serif hero figure on this screen — not unrealized + * gain/loss, which swings daily and isn't the kind of "certified statement figure" §3.1 reserves + * the serif for. Always computed from the full lot list, not the status-filtered table view, since + * this is a whole-account summary. */ +export function LotSummary({ lots }) { + const marketValue = totalMarketValue(lots) + const unrealizedGainLoss = totalUnrealizedGainLoss(lots) + const washSaleDisallowed = totalWashSaleDisallowed(lots) + const incomplete = hasMissingPrices(lots) + + return ( +
+ + Total market value + {marketValue === null ? '—' : formatMoney(marketValue)} + {incomplete && ( +

+ One or more open lots is missing a confirmed price — this total may be incomplete. +

+ )} +
+
+ + Unrealized gain/loss + {unrealizedGainLoss === null ? ( + — + ) : ( + + {formatMoney(unrealizedGainLoss)} + + )} + + + Wash sale disallowed + {formatMoney(washSaleDisallowed)} + +
+
+ ) +} diff --git a/frontend/src/features/lots/components/LotTable.css b/frontend/src/features/lots/components/LotTable.css index 8b39bd2..eef9eb1 100644 --- a/frontend/src/features/lots/components/LotTable.css +++ b/frontend/src/features/lots/components/LotTable.css @@ -1,3 +1,16 @@ +.tu-lot-table__security { + display: inline-flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.tu-lot-table__symbol { + font-weight: var(--font-weight-body-medium); +} + +/* Gains/losses in tables use success/error text color on the figure itself, not a badge + (design-system §2.4). */ .tu-lot-table__gain { color: var(--color-success); } diff --git a/frontend/src/features/lots/components/LotTable.jsx b/frontend/src/features/lots/components/LotTable.jsx index 729a1ad..5a078ef 100644 --- a/frontend/src/features/lots/components/LotTable.jsx +++ b/frontend/src/features/lots/components/LotTable.jsx @@ -1,58 +1,79 @@ import Decimal from 'decimal.js' -import { Fragment, useState } from 'react' -import { EmptyState } from '../../../components/EmptyState' -import { ErrorState } from '../../../components/ErrorState' -import { Skeleton } from '../../../components/Skeleton' +import { Fragment } from 'react' +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 { useLots } from '../hooks/useLots.js' +import { lotStatus } from '../utils/lotSummary.js' +import { HoldingPeriodBadge } from './HoldingPeriodBadge.jsx' import { LotDetail } from './LotDetail.jsx' +import { LotStatusBadge } from './LotStatusBadge.jsx' import { ProvisionalBadge } from './ProvisionalBadge.jsx' import './LotTable.css' -export function LotTable() { - const { status, lots, error, refetch } = useLots() - const [expandedId, setExpandedId] = useState(null) +const COLUMN_COUNT = 7 - if (status === 'idle' || status === 'loading') { - return ( -
- - -
- ) - } - - if (status === 'error') { - return - } - - if (lots.length === 0) { - return - } +/** 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 + * 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 }) { + const sortDirectionFor = (column) => (sort.column === column ? sort.direction : undefined) return ( - Security - Quantity - Adjusted basis - Unrealized gain/loss + onSort('symbol')}> + Security + + Status + onSort('quantity')}> + Quantity + + onSort('adjustedBasis')} + > + Adjusted basis + + onSort('marketValue')} + > + Market value + + onSort('unrealizedGainLoss')} + > + Unrealized gain/loss +   {lots.map((lot) => { - const currentValue = lot.current_price ? new Decimal(lot.quantity_remaining).times(lot.current_price) : null - const gainLoss = currentValue ? currentValue.minus(lot.adjusted_basis) : null + const status = lotStatus(lot) const isExpanded = expandedId === lot.id + const gainLoss = lot.unrealized_gain_loss === null ? null : new Decimal(lot.unrealized_gain_loss) return ( - setExpandedId(isExpanded ? null : lot.id)} aria-expanded={isExpanded}> + onToggleExpand(lot.id)} aria-expanded={isExpanded}> + + + {lot.symbol} + {lot.is_provisional && } + {status !== 'closed' && } + + - {lot.symbol} {lot.is_provisional && } + @@ -61,19 +82,24 @@ export function LotTable() { {formatMoney(lot.adjusted_basis)} - {gainLoss ? ( + {lot.market_value === null ? '—' : formatMoney(lot.market_value)} + + + {gainLoss === null ? ( + '—' + ) : ( {formatMoney(gainLoss)} - ) : ( - '—' )} - {isExpanded ? '▲' : '▼'} + {isExpanded && ( - diff --git a/frontend/src/features/lots/hooks/useLots.js b/frontend/src/features/lots/hooks/useLots.js index 21f5839..3963a19 100644 --- a/frontend/src/features/lots/hooks/useLots.js +++ b/frontend/src/features/lots/hooks/useLots.js @@ -1,10 +1,17 @@ -import { useCallback, useEffect, useState } from 'react' +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 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. */ export function useLots() { const [state, setState] = useState(IDLE) + const [statusFilter, setStatusFilter] = useState('all') + const [sort, setSort] = useState(DEFAULT_SORT) const refetch = useCallback(async () => { setState((prev) => ({ ...prev, status: 'loading', error: null })) @@ -20,5 +27,17 @@ export function useLots() { refetch() }, [refetch]) - return { ...state, refetch } + const toggleSort = useCallback((column) => { + setSort((prev) => { + if (prev.column !== column) return { column, direction: 'asc' } + return { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + }) + }, []) + + const visibleLots = useMemo( + () => sortLots(filterLotsByStatus(state.lots, statusFilter), sort), + [state.lots, statusFilter, sort], + ) + + return { ...state, visibleLots, statusFilter, setStatusFilter, sort, toggleSort, refetch } } diff --git a/frontend/src/features/lots/utils/lotSummary.js b/frontend/src/features/lots/utils/lotSummary.js new file mode 100644 index 0000000..5d20826 --- /dev/null +++ b/frontend/src/features/lots/utils/lotSummary.js @@ -0,0 +1,104 @@ +import Decimal from 'decimal.js' + +// Pure helpers for the Tax lots page — no React, no API calls. `GET /lots` (backend/app/views/lots.py) +// has no `status` field and no server-side sort/filter/pagination, so every derivation below runs +// client-side against the full lot list. Money/Units/Price fields are wire strings throughout — +// arithmetic always goes through decimal.js, never native +/* on a parsed float. + +/** + * A lot's status derived from `quantity_remaining` vs `quantity_opened`/`"0"` — the wire response + * has no `status` field of its own. `closed` first (a fully-sold lot regardless of how it opened), + * then `open` (nothing sold yet), else `partial`. + */ +export function lotStatus(lot) { + const remaining = new Decimal(lot.quantity_remaining) + if (remaining.isZero()) return 'closed' + const opened = new Decimal(lot.quantity_opened) + return remaining.equals(opened) ? 'open' : 'partial' +} + +/** `wash_sale_disallowed` is never null on the wire — `"0.0000"` reads as "no wash sale here." */ +export function hasWashSaleDisallowed(lot) { + return new Decimal(lot.wash_sale_disallowed).greaterThan(0) +} + +/** + * The correct IRS "day after acquisition" long-term test: a lot bought 2025-01-01 turns long-term + * on 2026-01-01, not after 365 days held (which would be off by one at the boundary). Built with + * the local 3-arg `Date` constructor throughout, never a parsed ISO string — `acquired_at` is a + * plain `YYYY-MM-DD` calendar date with no time zone of its own (same UTC-midnight trap + * `utils/format.js`'s `formatDate` documents and avoids). + */ +export function holdingPeriod(acquiredAtIsoDate, today = new Date()) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(acquiredAtIsoDate) + if (!match) { + throw new Error(`holdingPeriod: expected a plain YYYY-MM-DD date, got "${acquiredAtIsoDate}"`) + } + const [, yearStr, monthStr, dayStr] = match + const acquired = new Date(Number(yearStr), Number(monthStr) - 1, Number(dayStr)) + const longTermFrom = new Date(acquired.getFullYear() + 1, acquired.getMonth(), acquired.getDate()) + const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()) + return longTermFrom <= todayMidnight ? 'long-term' : 'short-term' +} + +/** True when any still-open (non-closed) lot has no confirmed price yet — a page-level totals note, + * never a silent `—` standing in for "this total might not include everything." */ +export function hasMissingPrices(lots) { + return lots.some((lot) => lotStatus(lot) !== 'closed' && lot.current_price === null) +} + +/** Sums only the lots that carry a non-null value for `field`; returns `null` (not zero) when none + * do, so "no data" stays distinguishable from "sums to zero." */ +function sumNullableField(lots, field) { + const contributors = lots.filter((lot) => lot[field] !== null) + if (contributors.length === 0) return null + return contributors.reduce((sum, lot) => sum.plus(lot[field]), new Decimal(0)) +} + +export function totalMarketValue(lots) { + return sumNullableField(lots, 'market_value') +} + +export function totalUnrealizedGainLoss(lots) { + return sumNullableField(lots, 'unrealized_gain_loss') +} + +/** `wash_sale_disallowed` is never null, so this total is always summable — never `null`. */ +export function totalWashSaleDisallowed(lots) { + return lots.reduce((sum, lot) => sum.plus(lot.wash_sale_disallowed), new Decimal(0)) +} + +export function filterLotsByStatus(lots, statusFilter) { + if (statusFilter === 'all') return lots + return lots.filter((lot) => lotStatus(lot) === statusFilter) +} + +const SORT_FIELD_BY_COLUMN = { + quantity: 'quantity_remaining', + adjustedBasis: 'adjusted_basis', + marketValue: 'market_value', + unrealizedGainLoss: 'unrealized_gain_loss', +} + +/** Sorts a copy of `lots`; nullable numeric columns (market value, unrealized gain/loss) always + * sort their `null` rows to the end regardless of direction, rather than treating null as zero. */ +export function sortLots(lots, { column, direction }) { + const dir = direction === 'desc' ? -1 : 1 + const sorted = [...lots] + + sorted.sort((a, b) => { + if (column === 'symbol') { + return dir * a.symbol.localeCompare(b.symbol) + } + const field = SORT_FIELD_BY_COLUMN[column] + if (!field) return 0 + const aValue = a[field] + const bValue = b[field] + if (aValue === null && bValue === null) return 0 + if (aValue === null) return 1 + if (bValue === null) return -1 + return dir * new Decimal(aValue).comparedTo(bValue) + }) + + return sorted +} diff --git a/frontend/src/pages/LotsPage.css b/frontend/src/pages/LotsPage.css new file mode 100644 index 0000000..dde3f68 --- /dev/null +++ b/frontend/src/pages/LotsPage.css @@ -0,0 +1,28 @@ +.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)); + } +} + +/* `.tu-page`'s own `margin: 0 auto` (PageLayout.css, centering it under `max-width`) is a + cross-axis auto margin on a flex item of `.tu-app-layout__main` (a column flex container) — + per the flexbox spec, a cross-axis auto margin opts the item OUT of stretch sizing entirely, so + `.tu-page` falls back to its own content-based width instead of the container's. With a wide + table inside, that content-based width is the table's full unwrapped min-content size, so the + whole page (not just the table) ends up wider than the viewport and the table's own + `.tu-table-wrapper` (Table.css's overflow-x: auto) never gets a narrow-enough box to actually + scroll inside. An explicit `width: 100%` gives the auto-margin centering something definite to + center within again; `min-width: 0` then lets that 100% actually shrink to the viewport instead + of being floored by the table's content size. Scoped to this page (the extra `tu-lots-page` + class) rather than touched at the shared PageLayout.css/Table.css source — every other `.tu-page` + user either has no table this wide or hasn't hit this at a narrow viewport yet. */ +.tu-page.tu-lots-page { + width: 100%; + min-width: 0; +} diff --git a/frontend/src/pages/LotsPage.jsx b/frontend/src/pages/LotsPage.jsx index 345523d..1e5c054 100644 --- a/frontend/src/pages/LotsPage.jsx +++ b/frontend/src/pages/LotsPage.jsx @@ -1,14 +1,79 @@ -import { SimulatedBadge } from '../components/SimulatedBadge' +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 -

- +
+

Tax lots

+ + + {visibleLots.length === 0 ? ( + + ) : ( + + )}
) } From 67ceba5eb6ac9a2b48a4bda5ea2366ce703991a9 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 22:46:47 -0700 Subject: [PATCH 4/8] feat(funding): extend cash-summary, add history endpoint, redesign the 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. --- backend/app/controllers/api/funding.py | 53 ++- .../models/ledger/settlement_obligation.py | 18 +- backend/app/services/identity/_shared.py | 46 +++ .../app/services/identity/deposit_service.py | 22 +- .../identity/funding_history_service.py | 100 ++++++ .../identity/funding_summary_service.py | 88 +++++ backend/app/views/funding.py | 38 ++- backend/tests/api/test_funding.py | 315 ++++++++++++++++++ .../test_funding_history_service.py | 243 ++++++++++++++ .../test_funding_summary_service.py | 219 ++++++++++++ .../src/features/funding/api/fundingApi.js | 1 + .../funding/components/BankLinkCard.css | 42 +++ .../funding/components/BankLinkCard.jsx | 79 +++++ .../funding/components/CashSummary.css | 5 + .../funding/components/CashSummary.jsx | 32 +- .../funding/components/DepositForm.jsx | 35 +- .../funding/components/DepositLimits.css | 45 +++ .../funding/components/DepositLimits.jsx | 42 +++ .../funding/components/FundingForm.css | 17 +- .../funding/components/FundingHistory.css | 5 + .../funding/components/FundingHistory.jsx | 50 --- .../components/FundingHistoryTable.jsx | 55 +++ .../funding/components/LinkBankButton.jsx | 33 ++ .../components/OutstandingBalanceBanner.css | 54 +++ .../components/OutstandingBalanceBanner.jsx | 38 +++ .../components/SettlementStatusPill.jsx | 10 + .../funding/components/WithdrawForm.jsx | 43 ++- .../features/funding/hooks/useCashPolicy.js | 25 -- .../src/features/funding/hooks/useFunding.js | 53 +++ .../features/funding/utils/fundingSummary.js | 113 +++++++ frontend/src/pages/FundingPage.css | 40 ++- frontend/src/pages/FundingPage.jsx | 160 ++++++--- 32 files changed, 1931 insertions(+), 188 deletions(-) create mode 100644 backend/app/services/identity/_shared.py create mode 100644 backend/app/services/identity/funding_history_service.py create mode 100644 backend/app/services/identity/funding_summary_service.py create mode 100644 backend/tests/integration/test_funding_history_service.py create mode 100644 backend/tests/integration/test_funding_summary_service.py create mode 100644 frontend/src/features/funding/components/BankLinkCard.css create mode 100644 frontend/src/features/funding/components/BankLinkCard.jsx create mode 100644 frontend/src/features/funding/components/DepositLimits.css create mode 100644 frontend/src/features/funding/components/DepositLimits.jsx create mode 100644 frontend/src/features/funding/components/FundingHistory.css delete mode 100644 frontend/src/features/funding/components/FundingHistory.jsx create mode 100644 frontend/src/features/funding/components/FundingHistoryTable.jsx create mode 100644 frontend/src/features/funding/components/LinkBankButton.jsx create mode 100644 frontend/src/features/funding/components/OutstandingBalanceBanner.css create mode 100644 frontend/src/features/funding/components/OutstandingBalanceBanner.jsx create mode 100644 frontend/src/features/funding/components/SettlementStatusPill.jsx delete mode 100644 frontend/src/features/funding/hooks/useCashPolicy.js create mode 100644 frontend/src/features/funding/hooks/useFunding.js create mode 100644 frontend/src/features/funding/utils/fundingSummary.js diff --git a/backend/app/controllers/api/funding.py b/backend/app/controllers/api/funding.py index 1b3bfd9..05f6660 100644 --- a/backend/app/controllers/api/funding.py +++ b/backend/app/controllers/api/funding.py @@ -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 @@ -39,6 +42,8 @@ CashSummaryResponse, CurrentBankLinkResponse, DepositResponse, + FundingHistoryEntryResponse, + FundingHistoryResponse, LinkTokenResponse, WithdrawalResponse, ) @@ -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 diff --git a/backend/app/models/ledger/settlement_obligation.py b/backend/app/models/ledger/settlement_obligation.py index f844f1f..b5fce4c 100644 --- a/backend/app/models/ledger/settlement_obligation.py +++ b/backend/app/models/ledger/settlement_obligation.py @@ -14,7 +14,7 @@ from enum import StrEnum from typing import TYPE_CHECKING -from sqlalchemy import DDL, Date, DateTime, ForeignKey, String, event +from sqlalchemy import DDL, Date, DateTime, ForeignKey, String, event, select from sqlalchemy import Enum as SQLAlchemyEnum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -23,6 +23,7 @@ from app.core.repository import BaseRepository from app.models.base import Base from app.models.ledger._enum import enum_values +from app.models.ledger.account import Account if TYPE_CHECKING: from app.core.uow import UnitOfWork @@ -108,7 +109,11 @@ class SettlementObligation(Base): class SettlementObligationRepository(BaseRepository[SettlementObligation]): - """No `customer_id_column`: per-customer reads compose with `posting`/`account`.""" + """No `customer_id_column`: per-customer reads compose with `posting`/`account`. For the same + reason, `list_for_customer` below joins `account` directly and is deliberately NOT routed + through `BaseRepository`'s `_tenant_scoped` filtering -- there is no `customer_id` column on + `SettlementObligation` itself to scope on, so the explicit `Account.customer_id ==` predicate + IS the application-layer tenant scope here, with RLS on `account` as the DB-level backstop.""" def __init__(self, uow: UnitOfWork) -> None: super().__init__(uow, entity=SettlementObligation) @@ -116,6 +121,15 @@ def __init__(self, uow: UnitOfWork) -> None: def get_by_id(self, obligation_id: uuid.UUID) -> SettlementObligation | None: return self.session.query(SettlementObligation).filter_by(id=obligation_id).first() + def list_for_customer(self, customer_id: uuid.UUID) -> list[SettlementObligation]: + statement = ( + select(SettlementObligation) + .join(Account, Account.id == SettlementObligation.account_id) + .where(Account.customer_id == customer_id) + .order_by(SettlementObligation.expected_settlement_date.desc()) + ) + return list(self.session.execute(statement).scalars().all()) + def confirm(self, obligation: SettlementObligation, *, confirmed_at: datetime) -> None: if obligation.status is not SettlementObligationStatus.PENDING: raise ValueError( diff --git a/backend/app/services/identity/_shared.py b/backend/app/services/identity/_shared.py new file mode 100644 index 0000000..b0544dd --- /dev/null +++ b/backend/app/services/identity/_shared.py @@ -0,0 +1,46 @@ +"""Query logic shared by more than one `app/services/identity/` service. + +Kept separate from any one service module so two independent callers (`DepositService`, +`FundingSummaryService`) can share a single implementation rather than drift apart (see +`deposited_on`'s own docstring). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import func, select + +from app.core.money import Money +from app.models.ledger.account import Account, AccountRole +from app.models.ledger.journal_entry import JournalEntry, JournalEntryType +from app.models.ledger.posting import Posting + +if TYPE_CHECKING: + import uuid + from datetime import date + + from app.services.identity.funding_uow import FundingUnitOfWork + + +def deposited_on(uow: FundingUnitOfWork, customer_id: uuid.UUID, *, effective_date: date) -> Money: + """The single definition of the per-day deposit cap's left-hand side (S2 §5.2 step 2). + `DepositService.initiate()` enforces against this; `FundingSummaryService` discloses it. Two + separate implementations would let the disclosed headroom silently drift from what's actually + enforced.""" + statement = ( + select(func.coalesce(func.sum(Posting.amount_money), 0)) + .join(Account, Account.id == Posting.account_id) + .join(JournalEntry, JournalEntry.id == Posting.journal_entry_id) + .where( + Posting.customer_id == customer_id, + Account.role == AccountRole.CASH, + JournalEntry.entry_type == JournalEntryType.DEPOSIT, + JournalEntry.effective_date == effective_date, + ) + ) + total = uow.session.execute(statement).scalar_one() + return Money(total) if total is not None else Money("0.00") + + +__all__ = ["deposited_on"] diff --git a/backend/app/services/identity/deposit_service.py b/backend/app/services/identity/deposit_service.py index 4b08efb..5bfa960 100644 --- a/backend/app/services/identity/deposit_service.py +++ b/backend/app/services/identity/deposit_service.py @@ -7,22 +7,22 @@ from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING -from sqlalchemy import func, select +from sqlalchemy import select -from app.core.money import Money from app.models.identity.bank_link import BankLinkStatus from app.models.identity.customer import AccountApprovalStatus, KycStatus 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.ledger.journal_entry import JournalEntryType from app.models.ledger.settlement_obligation import SettlementObligation from app.models.ops.inbound_event import InboundEvent, InboundEventSource +from app.services.identity._shared import deposited_on from app.services.ledger.posting_service import PostingLeg, PostingService if TYPE_CHECKING: from collections.abc import Callable from datetime import date + from app.core.money import Money from app.services.identity.funding_uow import FundingUnitOfWork _ACH_SETTLEMENT_CALENDAR_DAYS = 2 @@ -205,19 +205,7 @@ def _check_active_bank_link(self, customer_id: uuid.UUID) -> None: ) def _deposited_today(self, customer_id: uuid.UUID, *, today: date) -> Money: - statement = ( - select(func.coalesce(func.sum(Posting.amount_money), 0)) - .join(Account, Account.id == Posting.account_id) - .join(JournalEntry, JournalEntry.id == Posting.journal_entry_id) - .where( - Posting.customer_id == customer_id, - Account.role == AccountRole.CASH, - JournalEntry.entry_type == JournalEntryType.DEPOSIT, - JournalEntry.effective_date == today, - ) - ) - total = self._uow.session.execute(statement).scalar_one() - return Money(total) if total is not None else Money("0.00") + return deposited_on(self._uow, customer_id, effective_date=today) def _account_for(self, customer_id: uuid.UUID, *, role: AccountRole) -> Account: account = self._uow.session.execute( diff --git a/backend/app/services/identity/funding_history_service.py b/backend/app/services/identity/funding_history_service.py new file mode 100644 index 0000000..f0acfcf --- /dev/null +++ b/backend/app/services/identity/funding_history_service.py @@ -0,0 +1,100 @@ +"""`FundingHistoryService` (S2 §6) -- `GET /api/v1/funding/history`'s read model. + +Deposits and withdrawals only, most-recent-first, enriched with settlement status where one +exists. Reads `posting`/`journal_entry`/`account` directly, the same read-only join pattern +`CashPolicyService` and `app/services/valuation/history_service.py` already use over these tables. + +Two deliberate shapes, both load-bearing: + +- A bounced deposit's `correction` journal entry gets no row of its own. The deposit's own row's + `settlement_status='failed'` plus `failure_reason` is the complete disclosure; a bounce is a + status change on the SAME obligation, not a second event. `GET /valuation/history` still shows + the raw correction entry for anyone reading the full ledger. +- A withdrawal has no `settlement_obligation` row at all, so its `settlement_status`, + `expected_settlement_date`, and `failure_reason` are all `None` -- never a fabricated + "settled"/"n/a" value, which would assert a custodial confirmation that never happened. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from sqlalchemy import select + +from app.models.ledger.account import Account, AccountRole +from app.models.ledger.journal_entry import JournalEntry, JournalEntryType +from app.models.ledger.posting import Posting + +if TYPE_CHECKING: + import uuid + from datetime import date, datetime + + from app.core.money import Money + from app.core.watermark import Watermark + from app.models.ledger.settlement_obligation import SettlementObligationStatus + from app.services.identity.funding_uow import FundingUnitOfWork + + +@dataclass(frozen=True, slots=True) +class FundingHistoryEntry: + journal_entry_id: uuid.UUID + entry_type: JournalEntryType + effective_date: date + recorded_at: datetime + amount: Money + settlement_status: SettlementObligationStatus | None + expected_settlement_date: date | None + failure_reason: str | None + + +class FundingHistoryService: + def __init__(self, uow: FundingUnitOfWork) -> None: + self._uow = uow + + def history(self, customer_id: uuid.UUID, *, as_of: Watermark) -> 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.""" + statement = ( + select(JournalEntry, Posting) + .join(Posting, Posting.journal_entry_id == JournalEntry.id) + .join(Account, Account.id == Posting.account_id) + .where( + Posting.customer_id == customer_id, + Account.role == AccountRole.CASH, + JournalEntry.entry_type.in_( + (JournalEntryType.DEPOSIT, JournalEntryType.WITHDRAWAL) + ), + JournalEntry.recorded_at <= as_of.cutoff, + ) + .order_by(JournalEntry.effective_date.desc(), JournalEntry.recorded_at.desc()) + ) + rows = self._uow.session.execute(statement).all() + + obligations_by_entry = { + obligation.journal_entry_id: obligation + for obligation in self._uow.settlement_obligations.list_for_customer(customer_id) + } + + entries: list[FundingHistoryEntry] = [] + for entry, posting in rows: + obligation = obligations_by_entry.get(entry.id) + entries.append( + FundingHistoryEntry( + journal_entry_id=entry.id, + entry_type=entry.entry_type, + effective_date=entry.effective_date, + recorded_at=entry.recorded_at, + amount=posting.amount_money, + settlement_status=obligation.status if obligation is not None else None, + expected_settlement_date=( + obligation.expected_settlement_date if obligation is not None else None + ), + failure_reason=obligation.failure_reason if obligation is not None else None, + ) + ) + return entries + + +__all__ = ["FundingHistoryEntry", "FundingHistoryService"] diff --git a/backend/app/services/identity/funding_summary_service.py b/backend/app/services/identity/funding_summary_service.py new file mode 100644 index 0000000..c479651 --- /dev/null +++ b/backend/app/services/identity/funding_summary_service.py @@ -0,0 +1,88 @@ +"""`FundingSummaryService` (S2 §5.2/§6, FR-6) — assembles the funding page's cash read model. + +Mirrors `app/services/fees/fee_summary_service.py`'s shape: a small frozen dataclass plus a thin +assembly service built from a UoW and its collaborators. Unlike fees, the two deposit caps are +deploy-time settings the controller already reads from `get_settings()` -- this service takes them +as constructor arguments rather than reading config itself (services in this codebase never call +`get_settings()` directly). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from sqlalchemy import func, select + +from app.core.money import Money +from app.models.ledger.account import Account, AccountRole +from app.models.ledger.posting import Posting +from app.services.identity._shared import deposited_on + +if TYPE_CHECKING: + import uuid + from collections.abc import Callable + + from app.services.identity.funding_uow import FundingUnitOfWork + from app.services.ledger.cash_policy_service import CashPolicyService + + +@dataclass(frozen=True, slots=True) +class FundingCashSummary: + withdrawable: Money + investable: Money + outstanding_receivable: Money + deposit_cap_per_transaction: Money + deposit_cap_per_day: Money + deposited_today: Money + + +class FundingSummaryService: + def __init__( + self, + uow: FundingUnitOfWork, + *, + cash_policy: CashPolicyService, + deposit_cap_per_transaction: Money, + deposit_cap_per_day: Money, + now: Callable[[], datetime] = lambda: datetime.now(UTC), + ) -> None: + self._uow = uow + self._cash_policy = cash_policy + self._deposit_cap_per_transaction = deposit_cap_per_transaction + self._deposit_cap_per_day = deposit_cap_per_day + self._now = now + + def summarize(self, customer_id: uuid.UUID) -> FundingCashSummary: + withdrawable = self._cash_policy.withdrawable(customer_id) + investable = self._cash_policy.investable(customer_id) + deposited_today = deposited_on( + self._uow, customer_id, effective_date=self._now().date() + ) + outstanding_receivable = self._outstanding_receivable(customer_id) + return FundingCashSummary( + withdrawable=withdrawable, + investable=investable, + outstanding_receivable=outstanding_receivable, + deposit_cap_per_transaction=self._deposit_cap_per_transaction, + deposit_cap_per_day=self._deposit_cap_per_day, + deposited_today=deposited_today, + ) + + def _outstanding_receivable(self, customer_id: uuid.UUID) -> Money: + """`0.00` for the common case: a customer whose deposits have never bounced has no + `customer_receivable` account row at all.""" + statement = ( + select(func.coalesce(func.sum(Posting.amount_money), 0)) + .join(Account, Account.id == Posting.account_id) + .where( + Posting.customer_id == customer_id, + Account.role == AccountRole.CUSTOMER_RECEIVABLE, + ) + ) + total = self._uow.session.execute(statement).scalar_one() + return Money(total) if total is not None else Money("0.00") + + +__all__ = ["FundingCashSummary", "FundingSummaryService"] diff --git a/backend/app/views/funding.py b/backend/app/views/funding.py index 5ebfb62..0ae0ed5 100644 --- a/backend/app/views/funding.py +++ b/backend/app/views/funding.py @@ -19,10 +19,46 @@ class CashSummaryResponse(BaseModel): (S1 §5, ADR 5), never merged into one figure (frontend design-system §8.2: two equal-weight stat figures, load-bearing). `withdrawable` is confirmed-settled cash minus holds only; `investable` additionally counts unsettled inflows -- the asymmetry is deliberate (ADR 5), - not a bug, and both must reach the client exactly as computed, never merged into one number.""" + not a bug, and both must reach the client exactly as computed, never merged into one number. + + `outstanding_receivable` is the balance owed after a bounced deposit (S2 §5.2 step 4, FR-6 -- + "the customer-facing surface must show this as an outstanding balance the customer needs to + cover"); it is `0.0000`, never `null`, for every customer who's never had a deposit returned + (the overwhelming majority). The three cap fields exist so the client can show real headroom + and validate before submitting rather than only reacting to a 422.""" withdrawable: Money investable: Money + outstanding_receivable: Money + deposit_cap_per_transaction: Money + deposit_cap_per_day: Money + deposited_today: Money + + +class FundingHistoryEntryResponse(BaseModel): + """One deposit or withdrawal, carrying the SIGNED cash-leg amount (positive for a deposit, + negative for a withdrawal) -- one row per movement, never one per posting. + + `settlement_status` is nullable by design: a withdrawal carries no settlement obligation, so + its `settlement_status`, `expected_settlement_date`, and `failure_reason` are all `null` + rather than a fabricated value asserting a custodial confirmation that never happened. + `entry_type`/`settlement_status` are plain `str` here, as `BankLinkResponse.status` already + is; the controller passes `.value`.""" + + journal_entry_id: uuid.UUID + entry_type: str + effective_date: date + recorded_at: datetime + amount: Money + settlement_status: str | None + expected_settlement_date: date | None + failure_reason: str | None + + +class FundingHistoryResponse(BaseModel): + """`GET /api/v1/funding/history` -- deposits and withdrawals, most-recent-first.""" + + entries: list[FundingHistoryEntryResponse] class LinkTokenResponse(BaseModel): diff --git a/backend/tests/api/test_funding.py b/backend/tests/api/test_funding.py index dfe9fe0..6e5288d 100644 --- a/backend/tests/api/test_funding.py +++ b/backend/tests/api/test_funding.py @@ -6,11 +6,15 @@ import uuid from collections.abc import Iterator +from datetime import UTC, datetime +from decimal import Decimal import pytest from flask.testing import FlaskClient from sqlalchemy import Engine, select, text +from app.core.money import Money +from app.core.uow import SessionRole from app.integrations.fake.fake_bank import FakeBankAdapter from app.models.identity.bank_link import BankLink from app.models.identity.customer import AccountApprovalStatus, Customer, KycStatus @@ -23,6 +27,8 @@ from app.models.ops.inbound_event import InboundEvent 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_uow import FundingUnitOfWork CUSTOMER_EMAIL = "funding-customer@trueup.example" CUSTOMER_PASSWORD = "correct-horse-battery" @@ -332,3 +338,312 @@ def test_create_withdrawal_requires_idempotency_key(api_client: FlaskClient) -> assert response.status_code == 422 assert response.get_json()["code"] == "validation_failed" + + +# --- helpers for states with no HTTP path --------------------------------------------------- + + +def _confirm_obligation(customer_id: str, *, journal_entry_id: str) -> None: + """Drives `settlement_obligations.confirm(...)` directly -- nothing in `app/` calls it over + HTTP today (an ACH confirmation arrives out-of-band from the custodian in production).""" + with FundingUnitOfWork( + customer_id=uuid.UUID(customer_id), role=SessionRole.CUSTOMER + ) as uow: + obligations = uow.settlement_obligations.list_for_customer(uuid.UUID(customer_id)) + obligation = next(o for o in obligations if str(o.journal_entry_id) == journal_entry_id) + uow.settlement_obligations.confirm(obligation, confirmed_at=datetime.now(UTC)) + uow.commit() + + +def _bounce_deposit(customer_id: str, *, settlement_obligation_id: str) -> None: + """Drives `DepositService.apply_ach_return(...)` directly -- same rationale as + `_confirm_obligation`: the ACH return is a custodian-initiated event with no HTTP entry + point in this codebase (mirrors `test_deposit_service.py`'s own fixture pattern).""" + with FundingUnitOfWork( + customer_id=uuid.UUID(customer_id), role=SessionRole.CUSTOMER + ) as uow: + DepositService( + uow, + deposit_cap_per_transaction=Money("25000.00"), + deposit_cap_per_day=Money("50000.00"), + ).apply_ach_return(uuid.UUID(settlement_obligation_id)) + uow.commit() + + +# --- GET /cash-summary ----------------------------------------------------------------------- + + +def test_get_cash_summary_reports_the_caps_and_zero_usage_for_a_new_customer( + 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/cash-summary?customer_id={customer_id}") + + assert response.status_code == 200 + body = response.get_json() + assert body["outstanding_receivable"] == "0.0000" + assert body["deposit_cap_per_transaction"] == "25000.0000" + assert body["deposit_cap_per_day"] == "50000.0000" + assert body["deposited_today"] == "0.0000" + + +def test_get_cash_summary_counts_todays_deposit_in_deposited_today( + 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) + + before = api_client.get(f"/api/v1/funding/cash-summary?customer_id={customer_id}").get_json() + + api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": "500.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + after = api_client.get(f"/api/v1/funding/cash-summary?customer_id={customer_id}").get_json() + + assert after["deposited_today"] == "500.0000" + assert Decimal(after["investable"]) == Decimal(before["investable"]) + Decimal("500.0000") + assert after["withdrawable"] == before["withdrawable"] + + +def test_get_cash_summary_reports_the_receivable_balance_after_an_ach_return( + 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) + + deposit = api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": "500.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ).get_json() + + _bounce_deposit(customer_id, settlement_obligation_id=deposit["settlement_obligation_id"]) + + response = api_client.get(f"/api/v1/funding/cash-summary?customer_id={customer_id}") + + assert response.status_code == 200 + assert response.get_json()["outstanding_receivable"] == "500.0000" + + +def test_get_cash_summary_is_throttled(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) + + last_response = None + for _ in range(61): + last_response = api_client.get(f"/api/v1/funding/cash-summary?customer_id={customer_id}") + + assert last_response is not None + assert last_response.status_code == 429 + assert "Retry-After" in last_response.headers + + +# --- GET /funding/history ---------------------------------------------------------------------- + + +def test_get_funding_history_requires_authentication(api_client: FlaskClient) -> None: + response = api_client.get(f"/api/v1/funding/history?customer_id={uuid.uuid4()}") + + assert response.status_code == 401 + + +def test_get_funding_history_is_empty_for_a_new_customer( + 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}") + + assert response.status_code == 200 + assert response.get_json() == {"entries": []} + + +def test_get_funding_history_shows_a_new_deposit_as_pending_with_its_expected_settlement_date( + 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) + + deposit = api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": "500.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ).get_json() + + response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}") + + assert response.status_code == 200 + entries = response.get_json()["entries"] + assert len(entries) == 1 + entry = entries[0] + assert entry["journal_entry_id"] == deposit["journal_entry_id"] + assert entry["entry_type"] == "deposit" + assert entry["amount"] == "500.0000" + assert entry["settlement_status"] == "pending" + assert entry["expected_settlement_date"] == deposit["expected_settlement_date"] + assert entry["failure_reason"] is None + + +def test_get_funding_history_emits_one_row_per_deposit_not_one_per_posting( + 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) + + api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": "500.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}") + + assert len(response.get_json()["entries"]) == 1 + + +def test_get_funding_history_shows_a_confirmed_deposit_as_confirmed( + 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) + + deposit = api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": "500.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ).get_json() + + _confirm_obligation(customer_id, journal_entry_id=deposit["journal_entry_id"]) + + response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}") + + entries = response.get_json()["entries"] + assert len(entries) == 1 + assert entries[0]["settlement_status"] == "confirmed" + + +def test_get_funding_history_shows_a_returned_deposit_as_failed_with_its_reason( + 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) + + deposit = api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": "500.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ).get_json() + + _bounce_deposit(customer_id, settlement_obligation_id=deposit["settlement_obligation_id"]) + + response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}") + + entries = response.get_json()["entries"] + assert len(entries) == 1 + assert entries[0]["settlement_status"] == "failed" + assert entries[0]["failure_reason"] == "ach_return" + + +def test_get_funding_history_reports_a_withdrawal_with_no_settlement_status( + 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) + deposit = api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": "500.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ).get_json() + _confirm_obligation(customer_id, journal_entry_id=deposit["journal_entry_id"]) + + api_client.post( + "/api/v1/funding/withdrawals", + json={"customer_id": customer_id, "amount": "100.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}") + + entries = response.get_json()["entries"] + withdrawal = next(e for e in entries if e["entry_type"] == "withdrawal") + assert withdrawal["amount"] == "-100.0000" + assert withdrawal["settlement_status"] is None + assert withdrawal["expected_settlement_date"] is None + assert withdrawal["failure_reason"] is None + + +def test_get_funding_history_orders_newest_first( + 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) + + first = api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": "100.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ).get_json() + second = api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": customer_id, "amount": "200.00"}, + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ).get_json() + + response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}") + + entries = response.get_json()["entries"] + ids = [e["journal_entry_id"] for e in entries] + assert ids.index(second["journal_entry_id"]) < ids.index(first["journal_entry_id"]) + + +def test_get_funding_history_excludes_another_customers_activity( + api_client: FlaskClient, db_committing +) -> None: + other_id, other_csrf = _register_and_login(api_client, email=OTHER_EMAIL) + _link_bank(api_client, customer_id=other_id, csrf_token=other_csrf) + _approve_customer(db_committing, other_id) + api_client.post( + "/api/v1/funding/deposits", + json={"customer_id": other_id, "amount": "500.00"}, + headers={"X-CSRFToken": other_csrf, "Idempotency-Key": str(uuid.uuid4())}, + ) + api_client.post("/api/v1/auth/logout", headers={"X-CSRFToken": other_csrf}) + + 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}") + + assert response.get_json() == {"entries": []} + + +def test_get_funding_history_is_throttled(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) + + last_response = None + for _ in range(61): + last_response = api_client.get(f"/api/v1/funding/history?customer_id={customer_id}") + + assert last_response is not None + assert last_response.status_code == 429 + assert "Retry-After" in last_response.headers diff --git a/backend/tests/integration/test_funding_history_service.py b/backend/tests/integration/test_funding_history_service.py new file mode 100644 index 0000000..eb29acf --- /dev/null +++ b/backend/tests/integration/test_funding_history_service.py @@ -0,0 +1,243 @@ +"""`FundingHistoryService` (S2 §6) — against real Postgres: the `Account.role == CASH` join +guard and the signed-amount convention both depend on real posting/account rows.""" + +from __future__ import annotations + +import base64 +import uuid +from collections.abc import Iterator +from datetime import UTC, datetime + +import pytest +from sqlalchemy import Engine, text + +from app.config import Settings +from app.core.crypto import reset_cipher, set_cipher +from app.core.money import Money +from app.core.uow import SessionRole +from app.core.watermark import Watermark +from app.extensions import DbRole, dispose_engines, init_engines +from app.integrations.crypto.local_cipher import LocalDevCipher +from app.models.identity.bank_link import BankLink, BankLinkStatus +from app.models.identity.customer import AccountApprovalStatus, Customer, KycStatus +from app.models.ledger.account import Account, AccountRole +from app.models.ledger.customer_cash_lock import CustomerCashLock +from app.models.ledger.journal_entry import JournalEntry, JournalEntryType +from app.models.ledger.posting import Posting +from app.models.ledger.settlement_obligation import ( + SettlementObligation, + SettlementObligationStatus, +) +from app.models.ops.inbound_event import InboundEvent +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_uow import FundingUnitOfWork +from app.services.identity.withdrawal_service import WithdrawalService +from app.services.ledger.cash_policy_service import CashPolicyService +from app.services.orders.holds_provider import OrderHoldsProvider + +_TABLES = [ + Customer.__table__, + InboundEvent.__table__, + BankLink.__table__, + Account.__table__, + JournalEntry.__table__, + Posting.__table__, + SettlementObligation.__table__, + CustomerCashLock.__table__, + Order.__table__, + ApprovalHold.__table__, +] + +pytestmark = pytest.mark.usefixtures("_tables") + + +@pytest.fixture(autouse=True) +def _engines(test_settings: Settings) -> Iterator[None]: + init_engines(test_settings) + set_cipher(LocalDevCipher(base64.b64encode(b"0" * 32).decode())) + yield None + dispose_engines() + reset_cipher() + + +@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')) + + +def _approved_customer(db_committing) -> uuid.UUID: + customer = Customer( + email=f"{uuid.uuid4()}@trueup.test", + password_hash="hash", + kyc_status=KycStatus.approved, + account_approval_status=AccountApprovalStatus.approved, + ) + db_committing.add(customer) + db_committing.flush() + db_committing.add(Account.create(AccountRole.CASH, customer_id=customer.id)) + db_committing.add(Account.create(AccountRole.CUSTOMER_EQUITY, customer_id=customer.id)) + db_committing.add(CustomerCashLock(customer_id=customer.id)) + db_committing.commit() + return customer.id + + +def _active_link(db_committing, customer_id: uuid.UUID) -> None: + db_committing.add( + BankLink( + customer_id=customer_id, + plaid_item_id=f"item-{uuid.uuid4()}", + plaid_access_token="access-token", + status=BankLinkStatus.ACTIVE, + ) + ) + db_committing.commit() + + +def _deposit(customer_id: uuid.UUID, *, amount: Money, now: datetime): + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + result = DepositService( + uow, + deposit_cap_per_transaction=Money("25000.00"), + deposit_cap_per_day=Money("50000.00"), + now=lambda: now, + ).initiate(customer_id, amount=amount) + uow.commit() + return result + + +def _history(customer_id: uuid.UUID): + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + return FundingHistoryService(uow).history(customer_id, as_of=Watermark.live()) + + +def test_history_reports_signed_cash_leg_amounts_only(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + now = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + _deposit(customer_id, amount=Money("500.00"), now=now) + + entries = _history(customer_id) + + assert len(entries) == 1 + assert entries[0].amount == Money("500.00") + + +def test_history_excludes_the_correction_entry_for_a_returned_deposit(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + now = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + result = _deposit(customer_id, amount=Money("500.00"), now=now) + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + DepositService( + uow, + deposit_cap_per_transaction=Money("25000.00"), + deposit_cap_per_day=Money("50000.00"), + now=lambda: now, + ).apply_ach_return(result.settlement_obligation_id) + uow.commit() + + entries = _history(customer_id) + + assert len(entries) == 1 + assert entries[0].settlement_status == SettlementObligationStatus.FAILED + assert entries[0].amount == Money("500.00") + + +def test_each_deposit_maps_its_own_obligation_not_a_shared_or_stale_one(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + now = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + first = _deposit(customer_id, amount=Money("100.00"), now=now) + second = _deposit(customer_id, amount=Money("200.00"), now=now) + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + obligation = uow.settlement_obligations.get_by_id(first.settlement_obligation_id) + assert obligation is not None + uow.settlement_obligations.confirm(obligation, confirmed_at=now) + uow.commit() + + entries = {e.journal_entry_id: e for e in _history(customer_id)} + + assert entries[first.journal_entry_id].settlement_status == SettlementObligationStatus.CONFIRMED + assert entries[second.journal_entry_id].settlement_status == SettlementObligationStatus.PENDING + + +def test_history_orders_newest_first_by_effective_then_recorded_date(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + earlier = datetime(2026, 9, 4, 12, 0, tzinfo=UTC) + later = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + first = _deposit(customer_id, amount=Money("100.00"), now=earlier) + second = _deposit(customer_id, amount=Money("200.00"), now=later) + + entries = _history(customer_id) + + assert [e.journal_entry_id for e in entries] == [ + second.journal_entry_id, + first.journal_entry_id, + ] + + +def test_history_respects_the_watermark_cutoff(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + now = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + _deposit(customer_id, amount=Money("500.00"), now=now) + + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + entries = FundingHistoryService(uow).history( + customer_id, as_of=Watermark.as_published(datetime(2020, 1, 1, tzinfo=UTC)) + ) + + assert entries == [] + + +def test_history_includes_a_withdrawal_with_a_negative_amount(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + now = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + result = _deposit(customer_id, amount=Money("500.00"), now=now) + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + obligation = uow.settlement_obligations.get_by_id(result.settlement_obligation_id) + assert obligation is not None + uow.settlement_obligations.confirm(obligation, confirmed_at=now) + uow.commit() + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + cash_policy = CashPolicyService(uow, holds_provider=OrderHoldsProvider(uow)) + WithdrawalService(uow, cash_policy=cash_policy, now=lambda: now).initiate( + customer_id, amount=Money("100.00") + ) + uow.commit() + + entries = {e.entry_type: e for e in _history(customer_id)} + + withdrawal = entries[JournalEntryType.WITHDRAWAL] + assert withdrawal.amount == Money("-100.00") + assert withdrawal.settlement_status is None diff --git a/backend/tests/integration/test_funding_summary_service.py b/backend/tests/integration/test_funding_summary_service.py new file mode 100644 index 0000000..d11116c --- /dev/null +++ b/backend/tests/integration/test_funding_summary_service.py @@ -0,0 +1,219 @@ +"""`FundingSummaryService` (S2 §5.2/§6, FR-6) — against real Postgres, since it composes +`CashPolicyService` and a receivable-balance aggregate that both depend on RLS-scoped reads.""" + +from __future__ import annotations + +import base64 +import uuid +from collections.abc import Iterator +from datetime import UTC, date, datetime + +import pytest +from sqlalchemy import Engine, text + +from app.config import Settings +from app.core.crypto import reset_cipher, set_cipher +from app.core.money import Money +from app.core.uow import SessionRole +from app.extensions import DbRole, dispose_engines, init_engines +from app.integrations.crypto.local_cipher import LocalDevCipher +from app.models.identity.bank_link import BankLink, BankLinkStatus +from app.models.identity.customer import AccountApprovalStatus, Customer, KycStatus +from app.models.ledger.account import Account, AccountRole +from app.models.ledger.customer_cash_lock import CustomerCashLock +from app.models.ledger.journal_entry import JournalEntry +from app.models.ledger.posting import Posting +from app.models.ledger.settlement_obligation import SettlementObligation +from app.models.ops.inbound_event import InboundEvent +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_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 + +_TABLES = [ + Customer.__table__, + InboundEvent.__table__, + BankLink.__table__, + Account.__table__, + JournalEntry.__table__, + Posting.__table__, + SettlementObligation.__table__, + CustomerCashLock.__table__, + Order.__table__, + ApprovalHold.__table__, +] + +pytestmark = pytest.mark.usefixtures("_tables") + + +@pytest.fixture(autouse=True) +def _engines(test_settings: Settings) -> Iterator[None]: + init_engines(test_settings) + set_cipher(LocalDevCipher(base64.b64encode(b"0" * 32).decode())) + yield None + dispose_engines() + reset_cipher() + + +@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')) + + +def _approved_customer(db_committing) -> uuid.UUID: + customer = Customer( + email=f"{uuid.uuid4()}@trueup.test", + password_hash="hash", + kyc_status=KycStatus.approved, + account_approval_status=AccountApprovalStatus.approved, + ) + db_committing.add(customer) + db_committing.flush() + db_committing.add(Account.create(AccountRole.CASH, customer_id=customer.id)) + db_committing.add(Account.create(AccountRole.CUSTOMER_EQUITY, customer_id=customer.id)) + db_committing.add(CustomerCashLock(customer_id=customer.id)) + db_committing.commit() + return customer.id + + +def _active_link(db_committing, customer_id: uuid.UUID) -> None: + db_committing.add( + BankLink( + customer_id=customer_id, + plaid_item_id=f"item-{uuid.uuid4()}", + plaid_access_token="access-token", + status=BankLinkStatus.ACTIVE, + ) + ) + db_committing.commit() + + +def _summarize(customer_id: uuid.UUID, *, now: datetime): + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + cash_policy = CashPolicyService(uow, holds_provider=OrderHoldsProvider(uow)) + return FundingSummaryService( + uow, + cash_policy=cash_policy, + deposit_cap_per_transaction=Money("25000.00"), + deposit_cap_per_day=Money("50000.00"), + now=lambda: now, + ).summarize(customer_id) + + +def test_outstanding_receivable_is_zero_with_no_receivable_account_row(db_committing) -> None: + customer_id = _approved_customer(db_committing) + + summary = _summarize(customer_id, now=datetime(2026, 9, 5, 12, 0, tzinfo=UTC)) + + assert summary.outstanding_receivable == Money("0.00") + + +def test_outstanding_receivable_reflects_an_ach_return(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + now = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + service = DepositService( + uow, + deposit_cap_per_transaction=Money("25000.00"), + deposit_cap_per_day=Money("50000.00"), + now=lambda: now, + ) + result = service.initiate(customer_id, amount=Money("500.00")) + service.apply_ach_return(result.settlement_obligation_id) + uow.commit() + + summary = _summarize(customer_id, now=now) + + assert summary.outstanding_receivable == Money("500.00") + + +def test_deposited_today_excludes_prior_days_deposits(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + yesterday = datetime(2026, 9, 4, 12, 0, tzinfo=UTC) + today = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + DepositService( + uow, + deposit_cap_per_transaction=Money("25000.00"), + deposit_cap_per_day=Money("50000.00"), + now=lambda: yesterday, + ).initiate(customer_id, amount=Money("300.00")) + uow.commit() + + summary = _summarize(customer_id, now=today) + + assert summary.deposited_today == Money("0.00") + + +def test_deposited_today_excludes_non_deposit_postings(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + now = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + service = DepositService( + uow, + deposit_cap_per_transaction=Money("25000.00"), + deposit_cap_per_day=Money("50000.00"), + now=lambda: now, + ) + result = service.initiate(customer_id, amount=Money("300.00")) + service.apply_ach_return(result.settlement_obligation_id) + uow.commit() + + summary = _summarize(customer_id, now=now) + + assert summary.deposited_today == Money("300.00") + + +def test_caps_are_reported_unchanged_from_the_constructor_arguments(db_committing) -> None: + customer_id = _approved_customer(db_committing) + + summary = _summarize(customer_id, now=datetime(2026, 9, 5, 12, 0, tzinfo=UTC)) + + assert summary.deposit_cap_per_transaction == Money("25000.00") + assert summary.deposit_cap_per_day == Money("50000.00") + + +def test_withdrawable_and_investable_are_delegated_to_cash_policy(db_committing) -> None: + customer_id = _approved_customer(db_committing) + _active_link(db_committing, customer_id) + now = datetime(2026, 9, 5, 12, 0, tzinfo=UTC) + + with FundingUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + DepositService( + uow, + deposit_cap_per_transaction=Money("25000.00"), + deposit_cap_per_day=Money("50000.00"), + now=lambda: now, + ).initiate(customer_id, amount=Money("500.00")) + uow.commit() + + summary = _summarize(customer_id, now=now) + + # ADR 5: a same-day, unconfirmed deposit is investable but never withdrawable. + assert summary.investable == Money("500.00") + assert summary.withdrawable == Money("0.00") + assert date(2026, 9, 5) == now.date() diff --git a/frontend/src/features/funding/api/fundingApi.js b/frontend/src/features/funding/api/fundingApi.js index 7e8fbb3..878930c 100644 --- a/frontend/src/features/funding/api/fundingApi.js +++ b/frontend/src/features/funding/api/fundingApi.js @@ -6,4 +6,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'), } diff --git a/frontend/src/features/funding/components/BankLinkCard.css b/frontend/src/features/funding/components/BankLinkCard.css new file mode 100644 index 0000000..46424fb --- /dev/null +++ b/frontend/src/features/funding/components/BankLinkCard.css @@ -0,0 +1,42 @@ +.tu-bank-link-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; +} + +.tu-bank-link-card__info { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.tu-bank-link-card__label { + margin: 0; + font-size: var(--font-size-heading-sm); + line-height: var(--line-height-heading-sm); + font-weight: var(--font-weight-heading-sm); + color: var(--color-text); +} + +.tu-bank-link-card__description { + margin: 0; + font-size: var(--font-size-body); + line-height: var(--line-height-body); + color: var(--color-text-secondary); +} + +.tu-link-bank-button { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-2); +} + +.tu-link-bank-button__error { + margin: 0; + font-size: var(--font-size-caption); + color: var(--color-error); + text-align: right; +} diff --git a/frontend/src/features/funding/components/BankLinkCard.jsx b/frontend/src/features/funding/components/BankLinkCard.jsx new file mode 100644 index 0000000..c6bca43 --- /dev/null +++ b/frontend/src/features/funding/components/BankLinkCard.jsx @@ -0,0 +1,79 @@ +import { Card } from '../../../components/Card' +import { ErrorState } from '../../../components/ErrorState' +import { Icon } from '../../../components/Icon' +import { Skeleton } from '../../../components/Skeleton' +import { StatusPill } from '../../../components/StatusPill' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { LinkBankButton } from './LinkBankButton.jsx' +import './BankLinkCard.css' + +/** + * Bank-link status in the funding screen'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 + * `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 + * exists to fix on `/cash-summary`. + */ +export function BankLinkCard({ customerId, status, bankLink, error, onRetry, onLinked }) { + if (status === 'idle' || status === 'loading') { + return ( + + + + ) + } + + if (status === 'error') { + return ( + + + + ) + } + + if (bankLink?.status === 'active') { + return ( + +
+

Bank account

+

+ Your linked bank account is ready to fund deposits and receive withdrawals. +

+
+ }> + Linked + +
+ ) + } + + if (bankLink?.status === 'requires_reauth') { + return ( + +
+

Bank account

+

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

+ }> + Needs reconnecting + +
+ +
+ ) + } + + return ( + +
+

Bank account

+

+ Link a bank account to deposit into or withdraw from your investing account. +

+
+ +
+ ) +} diff --git a/frontend/src/features/funding/components/CashSummary.css b/frontend/src/features/funding/components/CashSummary.css index 037fd2d..19cddec 100644 --- a/frontend/src/features/funding/components/CashSummary.css +++ b/frontend/src/features/funding/components/CashSummary.css @@ -1,3 +1,8 @@ +/* No serif hero on this screen (design-system §3.1 reserves Source Serif 4 for one settled/ + certified figure, e.g. LotSummary.css's total-market-value hero). Withdrawable and Investable + are deliberately co-equal (§8.2) and both volatile — Investable moves with every order placed — + so neither is "the" statement figure the serif treatment implies. Do not "fix" this by promoting + one to a serif hero later without re-reading ADR 5 first. */ .tu-cash-summary { display: flex; gap: var(--space-8); diff --git a/frontend/src/features/funding/components/CashSummary.jsx b/frontend/src/features/funding/components/CashSummary.jsx index aa16bfd..63f64fc 100644 --- a/frontend/src/features/funding/components/CashSummary.jsx +++ b/frontend/src/features/funding/components/CashSummary.jsx @@ -1,31 +1,27 @@ -import { ErrorState } from '../../../components/ErrorState' -import { Skeleton } from '../../../components/Skeleton' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' import { formatMoney } from '../../../utils/format.js' -import { useCashPolicy } from '../hooks/useCashPolicy.js' import './CashSummary.css' -// design-system.md §8.2 -export function CashSummary({ emphasize }) { - const { status, withdrawable, investable, error, refetch } = useCashPolicy() - - if (status === 'idle' || status === 'loading') { - return - } - - if (status === 'error') { - return - } - +/** + * 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`). + * + * `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. + */ +export function CashSummary({ cashSummary, emphasize }) { return (
Withdrawable - {formatMoney(withdrawable)} + {formatMoney(cashSummary.withdrawable)}
Investable - {formatMoney(investable)} + {formatMoney(cashSummary.investable)}
) diff --git a/frontend/src/features/funding/components/DepositForm.jsx b/frontend/src/features/funding/components/DepositForm.jsx index 064e277..af984c8 100644 --- a/frontend/src/features/funding/components/DepositForm.jsx +++ b/frontend/src/features/funding/components/DepositForm.jsx @@ -5,15 +5,29 @@ import { useToast } from '../../../components/Toast' import { getFundingErrorMessage } from '../fundingErrorMessage.js' import { useDeposit } from '../hooks/useDeposit.js' import { parseAmount } from '../parseAmount.js' +import { validateDepositAmount } from '../utils/fundingSummary.js' +import { DepositLimits } from './DepositLimits.jsx' import './FundingForm.css' -export function DepositForm({ customerId }) { +/** `bankLinkLoading` covers the short window before `FundingPage`'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`). */ +function disabledReasonFor(bankLink, bankLinkLoading) { + if (bankLinkLoading) return null + if (!bankLink) return 'Link a bank account before you can deposit.' + if (bankLink.status === 'requires_reauth') return 'Reconnect your bank account before you can deposit.' + return null +} + +export function DepositForm({ customerId, cashSummary, bankLink, bankLinkLoading, onSubmitted }) { const { status, error, deposit } = useDeposit(customerId) const { showToast } = useToast() const [amount, setAmount] = useState('') const [validationMessage, setValidationMessage] = useState(null) const isSubmitting = status === 'submitting' + const disabledReason = disabledReasonFor(bankLink, bankLinkLoading) const handleSubmit = (event) => { event.preventDefault() @@ -23,16 +37,28 @@ export function DepositForm({ customerId }) { setValidationMessage('Enter a valid amount greater than zero.') return } + // Pre-submit check against the real caps; the server's own cap-exceeded response + // (getFundingErrorMessage below) stays the fallback for a race against `deposited_today`. + const capError = validateDepositAmount(parsed, cashSummary) + if (capError) { + setValidationMessage(capError.message) + return + } deposit(parsed) .then(() => { showToast({ message: 'Deposit submitted.', tone: 'success' }) setAmount('') + onSubmitted?.() }) .catch(() => {}) } const displayError = validationMessage || (status === 'error' ? getFundingErrorMessage(error) : null) + if (disabledReason) { + return

{disabledReason}

+ } + return (
setAmount(event.target.value)} placeholder="0.00" + error={displayError} required /> - {displayError && ( -

- {displayError} -

- )} + diff --git a/frontend/src/features/funding/components/DepositLimits.css b/frontend/src/features/funding/components/DepositLimits.css new file mode 100644 index 0000000..fec933c --- /dev/null +++ b/frontend/src/features/funding/components/DepositLimits.css @@ -0,0 +1,45 @@ +.tu-deposit-limits { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* Square ends, not a pill — design-system §6 restricts radius-full to toggles/avatars, matching + BillingPeriodProgress.css's own track treatment. */ +.tu-deposit-limits__track { + height: 4px; + border-radius: 0; + background: var(--color-surface-sunken); + overflow: hidden; +} + +/* text-secondary, not --color-accent: §2.4 reserves brass for interactive elements, current + selection, and the restated marker — a progress fill is none of those. */ +.tu-deposit-limits__fill { + height: 100%; + background: var(--color-text-secondary); + transition: width var(--duration-base) var(--ease-standard); +} + +.tu-deposit-limits--warning .tu-deposit-limits__fill { + background: var(--color-warning); +} + +.tu-deposit-limits__caption { + margin: 0; + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.tu-deposit-limits--warning .tu-deposit-limits__caption { + color: var(--color-warning); +} + +.tu-deposit-limits__warning-icon { + display: inline-flex; +} diff --git a/frontend/src/features/funding/components/DepositLimits.jsx b/frontend/src/features/funding/components/DepositLimits.jsx new file mode 100644 index 0000000..050f0d6 --- /dev/null +++ b/frontend/src/features/funding/components/DepositLimits.jsx @@ -0,0 +1,42 @@ +import { Icon } from '../../../components/Icon' +import { formatMoney } from '../../../utils/format.js' +import { depositHeadroom } from '../utils/fundingSummary.js' +import './DepositLimits.css' + +/** + * Deposit-cap visibility (S2 §5.2, `CashSummaryResponse`'s cap fields). Modeled on the fees + * screen's `BillingPeriodProgress`: no bar at all until the customer has actually used part of + * today's cap — a bar permanently sitting at 0% is noise, not information, so an untouched day + * shows only a plain caption stating both caps. Once something's used, a bar + caption appears; + * past 80% of the daily cap it escalates to `warning` (never `error` — being near a cap isn't a + * failure, and the form itself still stops an over-cap submit before it reaches the server). + */ +export function DepositLimits({ cashSummary }) { + const { perTransactionCap, dailyRemaining, percentUsed, isNearLimit, hasUsedToday } = depositHeadroom(cashSummary) + const dailyCap = formatMoney(cashSummary.deposit_cap_per_day) + + if (!hasUsedToday) { + return ( +

+ Up to {formatMoney(perTransactionCap)} per deposit, {dailyCap} per day. +

+ ) + } + + return ( +
+ + ) +} diff --git a/frontend/src/features/funding/components/FundingForm.css b/frontend/src/features/funding/components/FundingForm.css index bafc2c3..cb14b03 100644 --- a/frontend/src/features/funding/components/FundingForm.css +++ b/frontend/src/features/funding/components/FundingForm.css @@ -5,13 +5,12 @@ max-width: 320px; } -.tu-funding-form__error { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-sm); - background: var(--color-error-subtle); - color: var(--color-error); - font-size: var(--font-size-caption); +/* Form validation/submit errors now render through Input's own `error` prop (aria-invalid + + aria-describedby wired for real) instead of this disconnected sibling paragraph — kept only for + the disabled-with-reason state below, which has no associated field to attach to. */ +.tu-funding-form__disabled-reason { + margin: 0; + font-size: var(--font-size-body); + line-height: var(--line-height-body); + color: var(--color-text-secondary); } diff --git a/frontend/src/features/funding/components/FundingHistory.css b/frontend/src/features/funding/components/FundingHistory.css new file mode 100644 index 0000000..af4dc2e --- /dev/null +++ b/frontend/src/features/funding/components/FundingHistory.css @@ -0,0 +1,5 @@ +/* No overrides here — FundingHistoryTable relies entirely on the shared Table.css treatment. + Deliberately no `.tu-table td[data-numeric]` color rule for a withdrawal's (negative) amount, + unlike LotTable.css's gain/loss coloring: a withdrawal is not a semantic/negative state, so its + amount stays plain --color-text like a deposit's — the sign and the Type column already carry + the distinction (design-system §2.4). */ diff --git a/frontend/src/features/funding/components/FundingHistory.jsx b/frontend/src/features/funding/components/FundingHistory.jsx deleted file mode 100644 index f20ba1c..0000000 --- a/frontend/src/features/funding/components/FundingHistory.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import { EmptyState } from '../../../components/EmptyState' -import { ErrorState } from '../../../components/ErrorState' -import { Skeleton } from '../../../components/Skeleton' -import { Table } from '../../../components/Table' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' -import { formatDate, formatMoney } from '../../../utils/format.js' -// No dedicated funding-history endpoint; reuses valuation's transaction history hook. -import { useTransactionHistory } from '../../valuation/hooks/useTransactionHistory.js' - -const FUNDING_ENTRY_TYPES = new Set(['deposit', 'withdrawal']) -const ENTRY_LABEL = { deposit: 'Deposit', withdrawal: 'Withdrawal' } - -export function FundingHistory() { - const { status, entries, error, refetch } = useTransactionHistory() - - if (status === 'idle' || status === 'loading') { - return - } - - if (status === 'error') { - return - } - - const fundingEntries = entries.filter((entry) => FUNDING_ENTRY_TYPES.has(entry.entry_type)) - - if (fundingEntries.length === 0) { - return - } - - return ( -
+
- - Date - Type - Amount - - - {fundingEntries.map((entry, index) => ( - - {formatDate(entry.effective_date)} - {ENTRY_LABEL[entry.entry_type]} - - {entry.amount_money ? formatMoney(entry.amount_money) : '—'} - - - ))} - -
- ) -} diff --git a/frontend/src/features/funding/components/FundingHistoryTable.jsx b/frontend/src/features/funding/components/FundingHistoryTable.jsx new file mode 100644 index 0000000..58bea0f --- /dev/null +++ b/frontend/src/features/funding/components/FundingHistoryTable.jsx @@ -0,0 +1,55 @@ +import { EmptyState } from '../../../components/EmptyState' +import { Table } from '../../../components/Table' +import { formatDate, formatMoney } from '../../../utils/format.js' +import { formatFailureReason, hasFundingActivity } from '../utils/fundingSummary.js' +import { SettlementStatusPill } from './SettlementStatusPill.jsx' +import './FundingHistory.css' + +const ENTRY_LABEL = { deposit: 'Deposit', withdrawal: 'Withdrawal' } + +/** + * Presentational — `entries` arrives already sorted from `FundingPage.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). + */ +export function FundingHistoryTable({ entries, sort, onSort }) { + 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) : '—'} + + ) + })} + +
+ ) +} diff --git a/frontend/src/features/funding/components/LinkBankButton.jsx b/frontend/src/features/funding/components/LinkBankButton.jsx new file mode 100644 index 0000000..1450f91 --- /dev/null +++ b/frontend/src/features/funding/components/LinkBankButton.jsx @@ -0,0 +1,33 @@ +import { Button } from '../../../components/Button' +import { getErrorMessage } from '../../../utils/apiErrorMessage.js' +import { useBankLink } from '../../onboarding/hooks/useBankLink.js' + +/** + * Thin wrapper around onboarding's real Plaid Link integration (`useBankLink`) — the funding + * screen has its own trigger copy (`label`, "Link bank account" vs. "Reconnect bank") but no + * separate bank-link mechanism of its own; do not duplicate or modify the hook itself. + */ +export function LinkBankButton({ customerId, label, onLinked }) { + const { tokenStatus, status, error, ready, open, retryLinkToken } = useBankLink(customerId, { onLinked }) + + if (tokenStatus === 'error') { + return ( + + ) + } + + return ( +
+ + {status === 'error' && ( +

+ {getErrorMessage(error)} +

+ )} +
+ ) +} diff --git a/frontend/src/features/funding/components/OutstandingBalanceBanner.css b/frontend/src/features/funding/components/OutstandingBalanceBanner.css new file mode 100644 index 0000000..0b2f221 --- /dev/null +++ b/frontend/src/features/funding/components/OutstandingBalanceBanner.css @@ -0,0 +1,54 @@ +/* Hairline-and-rule vocabulary (design-system §6) — a left accent rule does the emphasis, no + shadow. Matches DunningBanner.css's `--error` treatment exactly. */ +.tu-outstanding-balance-banner { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-3); + align-items: start; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + background: var(--color-error-subtle); + border-left: 3px solid var(--color-error); + color: var(--color-error); +} + +/* The icon inherits the error color via currentColor; the body below overrides back to the + primary text color so only the icon (and the rule) carry the semantic tint. */ +.tu-outstanding-balance-banner__icon { + display: flex; + padding-top: 2px; +} + +.tu-outstanding-balance-banner__body { + display: flex; + flex-direction: column; + gap: var(--space-3); + color: var(--color-text); +} + +.tu-outstanding-balance-banner__message { + margin: 0; + font-size: var(--font-size-body); + line-height: var(--line-height-body); +} + +/* A money figure — never text-muted, and here not even the banner's own error color (design + system §2.3: "any figure that is a balance ... uses text or text-secondary ... never + text-muted"). The error tint is carried by the icon/rule/background already; dimming or + recoloring the number itself would read as the figure being less real than any other balance + on the page, which it is not. */ +.tu-outstanding-balance-banner__amount { + color: var(--color-text); + font-weight: var(--font-weight-data-strong); + font-variant-numeric: tabular-nums; +} + +.tu-outstanding-balance-banner__actions { + display: flex; +} + +@media (max-width: 479px) { + .tu-outstanding-balance-banner__actions .tu-button { + width: 100%; + } +} diff --git a/frontend/src/features/funding/components/OutstandingBalanceBanner.jsx b/frontend/src/features/funding/components/OutstandingBalanceBanner.jsx new file mode 100644 index 0000000..b2c00ae --- /dev/null +++ b/frontend/src/features/funding/components/OutstandingBalanceBanner.jsx @@ -0,0 +1,38 @@ +import { Button } from '../../../components/Button' +import { Icon } from '../../../components/Icon' +import { formatMoney } from '../../../utils/format.js' +import { hasOutstandingBalance } from '../utils/fundingSummary.js' +import './OutstandingBalanceBanner.css' + +/** + * FR-6: a bounced deposit leaves a receivable balance the customer owes. Modeled on the fees + * screen's `DunningBanner` (grid `auto 1fr`, error-tinted left rule, `role="alert" + * aria-live="assertive"`) — but always `error`, never `warning`: unlike a first missed fee + * payment, an outstanding funding balance has no "will retry automatically" step, so there's no + * softer first-miss state to distinguish it from. + */ +export function OutstandingBalanceBanner({ cashSummary, onMakeDeposit }) { + if (!hasOutstandingBalance(cashSummary)) { + return null + } + + return ( +
+ + + +
+

+ Balance owed:{' '} + {formatMoney(cashSummary.outstanding_receivable)} — a + previous deposit was returned by your bank. Make a deposit of at least this amount to cover it. +

+
+ +
+
+
+ ) +} diff --git a/frontend/src/features/funding/components/SettlementStatusPill.jsx b/frontend/src/features/funding/components/SettlementStatusPill.jsx new file mode 100644 index 0000000..1e9235a --- /dev/null +++ b/frontend/src/features/funding/components/SettlementStatusPill.jsx @@ -0,0 +1,10 @@ +import { Icon } from '../../../components/Icon' +import { StatusPill } from '../../../components/StatusPill' +import { settlementDisplay } from '../utils/fundingSummary.js' + +/** Thin `settlementDisplay()` -> `StatusPill` wrapper for one `FundingHistoryTable` row. */ +export function SettlementStatusPill({ entry }) { + const { label, tone, icon } = settlementDisplay(entry) + + return : undefined}>{label} +} diff --git a/frontend/src/features/funding/components/WithdrawForm.jsx b/frontend/src/features/funding/components/WithdrawForm.jsx index 7538030..ee7df30 100644 --- a/frontend/src/features/funding/components/WithdrawForm.jsx +++ b/frontend/src/features/funding/components/WithdrawForm.jsx @@ -1,20 +1,34 @@ +import Decimal from 'decimal.js' import { useState } from 'react' import { Button } from '../../../components/Button' import { Input } from '../../../components/Input' import { useToast } from '../../../components/Toast' -import { useWithdraw } from '../hooks/useWithdraw.js' import { getFundingErrorMessage } from '../fundingErrorMessage.js' +import { useWithdraw } from '../hooks/useWithdraw.js' import { parseAmount } from '../parseAmount.js' +import { validateWithdrawalAmount } from '../utils/fundingSummary.js' import { CashSummary } from './CashSummary.jsx' import './FundingForm.css' -export function WithdrawForm({ customerId }) { +/** See `DepositForm.jsx`'s `disabledReasonFor` for why `bankLinkLoading` short-circuits to "not + * disabled yet" rather than guessing. A withdrawable balance of exactly zero gets its own stated + * reason rather than letting the submit round-trip just to fail. */ +function disabledReasonFor(bankLink, bankLinkLoading, cashSummary) { + if (bankLinkLoading) return null + if (!bankLink) return 'Link a bank account before you can withdraw.' + if (bankLink.status === 'requires_reauth') return 'Reconnect your bank account before you can withdraw.' + if (new Decimal(cashSummary.withdrawable).isZero()) return 'You have no withdrawable cash right now.' + return null +} + +export function WithdrawForm({ customerId, cashSummary, bankLink, bankLinkLoading, onSubmitted }) { const { status, error, withdraw } = useWithdraw(customerId) const { showToast } = useToast() const [amount, setAmount] = useState('') const [validationMessage, setValidationMessage] = useState(null) const isSubmitting = status === 'submitting' + const disabledReason = disabledReasonFor(bankLink, bankLinkLoading, cashSummary) const handleSubmit = (event) => { event.preventDefault() @@ -24,19 +38,36 @@ export function WithdrawForm({ customerId }) { setValidationMessage('Enter a valid amount greater than zero.') return } + // Validates against withdrawable only, never investable (ADR 5) — the server's own + // insufficient_withdrawable_cash response stays the fallback for a race. + const cashError = validateWithdrawalAmount(parsed, cashSummary) + if (cashError) { + setValidationMessage(cashError.message) + return + } withdraw(parsed) .then(() => { showToast({ message: 'Withdrawal submitted.', tone: 'success' }) setAmount('') + onSubmitted?.() }) .catch(() => {}) } const displayError = validationMessage || (status === 'error' ? getFundingErrorMessage(error) : null) + if (disabledReason) { + return ( +
+ +

{disabledReason}

+
+ ) + } + return ( - + setAmount(event.target.value)} placeholder="0.00" + error={displayError} required /> - {displayError && ( -

- {displayError} -

- )} diff --git a/frontend/src/features/funding/hooks/useCashPolicy.js b/frontend/src/features/funding/hooks/useCashPolicy.js deleted file mode 100644 index 367d7dd..0000000 --- a/frontend/src/features/funding/hooks/useCashPolicy.js +++ /dev/null @@ -1,25 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { fundingApi } from '../api/fundingApi.js' - -const IDLE = { status: 'idle', withdrawable: null, investable: null, error: null } - -// design-system.md §8.2 -export function useCashPolicy() { - const [state, setState] = useState(IDLE) - - const refetch = useCallback(async () => { - setState((prev) => ({ ...prev, status: 'loading', error: null })) - try { - const data = await fundingApi.getCashSummary() - setState({ status: 'loaded', withdrawable: data.withdrawable, investable: data.investable, error: null }) - } catch (error) { - setState({ status: 'error', withdrawable: null, investable: null, error }) - } - }, []) - - useEffect(() => { - refetch() - }, [refetch]) - - return { ...state, refetch } -} diff --git a/frontend/src/features/funding/hooks/useFunding.js b/frontend/src/features/funding/hooks/useFunding.js new file mode 100644 index 0000000..91cff44 --- /dev/null +++ b/frontend/src/features/funding/hooks/useFunding.js @@ -0,0 +1,53 @@ +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/features/funding/utils/fundingSummary.js b/frontend/src/features/funding/utils/fundingSummary.js new file mode 100644 index 0000000..bf5916f --- /dev/null +++ b/frontend/src/features/funding/utils/fundingSummary.js @@ -0,0 +1,113 @@ +import Decimal from 'decimal.js' +import { formatMoney } from '../../../utils/format.js' + +// Pure helpers for the Funding page — no React, no API calls. Money fields are wire strings +// throughout (`GET /funding/cash-summary`/`GET /funding/history`, backend/app/views/funding.py) — +// arithmetic always goes through decimal.js, never native +/* on a parsed float. + +/** `outstanding_receivable` is `"0.0000"`, never `null`, for a customer who's never had a deposit + * bounce (FR-6) — a real zero check, not a falsy/presence check. */ +export function hasOutstandingBalance(cashSummary) { + return new Decimal(cashSummary.outstanding_receivable).greaterThan(0) +} + +/** + * Today's remaining deposit headroom against both caps (S2 §5.2). `dailyRemaining` is what's left + * of the whole-day cap regardless of transaction size; `maxAllowed` is the tighter of the two caps + * — the actual ceiling a single deposit right now can hit. `percentUsed` is a plain JS number + * (0-100) for CSS width/threshold use, not a Money value. + */ +export function depositHeadroom(cashSummary) { + const perTransactionCap = new Decimal(cashSummary.deposit_cap_per_transaction) + const dailyCap = new Decimal(cashSummary.deposit_cap_per_day) + const depositedToday = new Decimal(cashSummary.deposited_today) + const dailyRemaining = Decimal.max(0, dailyCap.minus(depositedToday)) + const maxAllowed = Decimal.min(perTransactionCap, dailyRemaining) + const percentUsed = dailyCap.isZero() ? 0 : Math.min(100, depositedToday.div(dailyCap).times(100).toNumber()) + + return { + dailyRemaining: dailyRemaining.toFixed(2), + perTransactionCap: perTransactionCap.toFixed(2), + maxAllowed: maxAllowed.toFixed(2), + percentUsed, + // >= 80% of the daily cap, mirroring BillingPeriodProgress's own escalation threshold. + isNearLimit: percentUsed >= 80, + hasUsedToday: depositedToday.greaterThan(0), + } +} + +/** Pre-submit client-side check against the real caps (S2 §5.2) — the server's own + * `deposit_cap_exceeded_per_transaction`/`_per_day` response (fundingErrorMessage.js) stays the + * fallback source of truth for a race (the cap changing, or `deposited_today` moving between page + * load and submit). `parsedAmount` is already a 2dp Money string from `parseAmount`. */ +export function validateDepositAmount(parsedAmount, cashSummary) { + const amount = new Decimal(parsedAmount) + const perTransactionCap = new Decimal(cashSummary.deposit_cap_per_transaction) + if (amount.greaterThan(perTransactionCap)) { + return { + code: 'deposit_cap_exceeded_per_transaction', + message: `That's more than the ${formatMoney(perTransactionCap)} per-deposit limit.`, + } + } + + const { dailyRemaining } = depositHeadroom(cashSummary) + if (amount.greaterThan(dailyRemaining)) { + return { + code: 'deposit_cap_exceeded_per_day', + message: `That would put you over today's deposit limit — you have ${formatMoney(dailyRemaining)} left today.`, + } + } + + return null +} + +/** Validates against `withdrawable` only, never `investable` (ADR 5) — mixing the two is a + * regulatory-grade bug in this codebase, not a cosmetic one. */ +export function validateWithdrawalAmount(parsedAmount, cashSummary) { + const amount = new Decimal(parsedAmount) + const withdrawable = new Decimal(cashSummary.withdrawable) + if (amount.greaterThan(withdrawable)) { + return { + code: 'insufficient_withdrawable_cash', + message: `That's more than your withdrawable cash of ${formatMoney(withdrawable)}.`, + } + } + + return null +} + +const DEPOSIT_SETTLEMENT_DISPLAY = { + // Neutral, not warning: pending is the normal first-48-hours state of every deposit, not a + // problem in progress (design-system §8.3/§8.8's "disclosure, not a warning" precedent). + pending: { label: 'Pending', tone: 'neutral', icon: 'history' }, + confirmed: { label: 'Settled', tone: 'success', icon: 'check-circle' }, + failed: { label: 'Returned', tone: 'error', icon: 'alert-circle' }, +} + +/** A withdrawal carries no settlement obligation of its own (`settlement_status` is always `null` + * for one — views/funding.py's own note) — that's read as "Sent", never a fabricated status, and + * stays neutral rather than borrowing a deposit's semantic-color meaning. */ +export function settlementDisplay(entry) { + if (entry.entry_type === 'withdrawal') { + return { label: 'Sent', tone: 'neutral', icon: null } + } + return DEPOSIT_SETTLEMENT_DISPLAY[entry.settlement_status] ?? DEPOSIT_SETTLEMENT_DISPLAY.pending +} + +const FAILURE_REASON_LABELS = { + // The one reason code the simulated ACH-return job actually produces (deposit_service.py). + ach_return: 'Returned by your bank (ACH return)', +} + +/** Never renders a raw snake_case code — a known code gets real copy, an unrecognized one still + * gets humanized (underscores to spaces, capitalized) rather than shown verbatim. */ +export function formatFailureReason(reason) { + if (!reason) return null + if (FAILURE_REASON_LABELS[reason]) return FAILURE_REASON_LABELS[reason] + const humanized = reason.replace(/_/g, ' ') + return humanized.charAt(0).toUpperCase() + humanized.slice(1) +} + +export function hasFundingActivity(entries) { + return entries.length > 0 +} diff --git a/frontend/src/pages/FundingPage.css b/frontend/src/pages/FundingPage.css index 642a083..b68060a 100644 --- a/frontend/src/pages/FundingPage.css +++ b/frontend/src/pages/FundingPage.css @@ -1,11 +1,39 @@ +.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: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr); gap: var(--space-6); + align-items: start; } -.tu-funding-page__bank-status-label { - font-size: var(--font-size-caption); - color: var(--color-text-secondary); - margin: 0 0 var(--space-2); +/* 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)); + } +} + +/* Matches LotsPage.css's own fix: FundingHistoryTable is wide enough to need its own horizontal + scroll (Table.css), which only works if this page's width is pinned to the viewport rather than + growing to the table's unwrapped content width via PageLayout.css's auto-margin centering. */ +.tu-page.tu-funding-page { + width: 100%; + min-width: 0; +} + +/* 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 index 6698959..734e62b 100644 --- a/frontend/src/pages/FundingPage.jsx +++ b/frontend/src/pages/FundingPage.jsx @@ -1,71 +1,145 @@ +import { useCallback, useMemo, useRef, useState } from 'react' import { Card } from '../components/Card' import { ErrorState } from '../components/ErrorState' import { Skeleton } from '../components/Skeleton' -import { StatusPill } from '../components/StatusPill' 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 { FundingHistory } from '../features/funding/components/FundingHistory.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 { BankLinkStep } from '../features/onboarding/components/BankLinkStep.jsx' +import { useFunding } from '../features/funding/hooks/useFunding.js' +import { getErrorMessage } from '../utils/apiErrorMessage.js' import './FundingPage.css' import './PageLayout.css' -function BankLinkStatusCard({ customerId }) { - const currentBankLink = useCurrentBankLink() - - if (currentBankLink.status === 'idle' || currentBankLink.status === 'loading') { - return - } - - if (currentBankLink.status === 'error') { - return - } - - if (currentBankLink.bankLink?.status === 'active') { - return ( +function FundingPageSkeleton() { + return ( + <> -

Bank account

- Linked +
- ) - } - - return ( - - {currentBankLink.bankLink?.status === 'requires_reauth' && ( - <> -

Bank account

- Needs reconnecting - - )} - -
+ + + +
+ + + + + + + + +
+ + + + ) } +/** 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

- - +
+
+

Funding

+

Deposit into or withdraw from your investing account.

+
+ + + - +
- - - - - - +
+

+ Deposit +

+ + + +
+
+

Withdraw

+ + + +

Funding history

- +
) From 13688f4447a406f6cce0575f862a31b9d71435aa Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 22:46:59 -0700 Subject: [PATCH 5/8] feat(portfolio): add holdings and performance endpoints (ADR 26) 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/. 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. --- backend/app/controllers/api/portfolios.py | 106 +++++- .../models/marketdata/sub_period_return.py | 38 ++ .../rebalance/portfolio_holdings_service.py | 89 +++++ .../portfolio_performance_service.py | 118 ++++++ backend/app/views/portfolios.py | 66 ++++ backend/tests/api/test_portfolios_holdings.py | 338 ++++++++++++++++++ .../tests/api/test_portfolios_performance.py | 214 +++++++++++ .../test_portfolio_holdings_service.py | 274 ++++++++++++++ .../test_portfolio_performance_service.py | 238 ++++++++++++ .../26-portfolio-performance-series.md | 98 +++++ 10 files changed, 1578 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/rebalance/portfolio_holdings_service.py create mode 100644 backend/app/services/valuation/portfolio_performance_service.py create mode 100644 backend/tests/api/test_portfolios_holdings.py create mode 100644 backend/tests/api/test_portfolios_performance.py create mode 100644 backend/tests/integration/test_portfolio_holdings_service.py create mode 100644 backend/tests/integration/test_portfolio_performance_service.py create mode 100644 docs/decisions/26-portfolio-performance-series.md diff --git a/backend/app/controllers/api/portfolios.py b/backend/app/controllers/api/portfolios.py index 66e064e..f33380e 100644 --- a/backend/app/controllers/api/portfolios.py +++ b/backend/app/controllers/api/portfolios.py @@ -15,19 +15,32 @@ from pydantic import BaseModel from pydantic import ValidationError as PydanticValidationError +from app.config import get_settings from app.core.clock import MarketClock -from app.core.errors import ForbiddenError, UnauthenticatedError, ValidationError +from app.core.errors import ForbiddenError, NotFoundError, UnauthenticatedError, ValidationError from app.core.security import requires_role from app.core.uow import SessionRole +from app.core.watermark import Watermark from app.extensions import limiter from app.models.marketdata.trading_calendar import CachedTradingCalendar from app.models.rebalance.customer_model_assignment import CustomerModelAssignment +from app.services.rebalance.drift_evaluation_service import NoAssignedModelError +from app.services.rebalance.portfolio_holdings_service import HoldingLine, PortfolioHoldingsService from app.services.rebalance.uow import RebalanceUnitOfWork +from app.services.valuation.portfolio_performance_service import ( + PerformanceRange, + PortfolioPerformanceService, +) +from app.services.valuation.uow import ValuationUnitOfWork from app.views.portfolios import ( AssignmentResponse, CurrentAssignmentResponse, + HoldingResponse, ModelPortfolioListResponse, ModelPortfolioResponse, + PerformancePointResponse, + PortfolioHoldingsResponse, + PortfolioPerformanceResponse, TargetWeightResponse, ) @@ -41,6 +54,14 @@ class AssignModelRequest(BaseModel): model_portfolio_id: uuid.UUID +class _PerformanceQuery(BaseModel): + """`range` defaults to `1m` here -- the controller edge, never the service (ADR 26's + no-silent-default convention for `PortfolioPerformanceService.performance()`'s own + `range` argument).""" + + range: PerformanceRange = "1m" + + def _resolve_customer_id() -> uuid.UUID: """A `customer` session sees its own assignment; staff must name whose.""" if current_user.role == "customer": @@ -165,4 +186,87 @@ def _to_assignment_response(assignment: CustomerModelAssignment) -> AssignmentRe ) +@portfolios_bp.route("/holdings", methods=["GET"]) +@limiter.limit("60 per minute") +@requires_role("customer", *_STAFF_ROLES) +def holdings() -> Any: + """Live, per-security holdings against the customer's assigned model (Portfolio page + redesign) -- distinct from `GET /portfolios/models`' target-weight-only view. No assigned + model is a distinguishable 404, never a 500 or an empty-looking 200 (`DriftEvaluationService` + cannot evaluate drift with nothing to evaluate against).""" + customer_id = _resolve_customer_id() + with RebalanceUnitOfWork( + 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)) + + try: + result = PortfolioHoldingsService( + uow, drift_band_pct=get_settings().drift_band_pct + ).holdings(customer_id, as_of_date) + except NoAssignedModelError as exc: + raise NotFoundError(str(exc), code="no_model_assigned") from exc + + view = PortfolioHoldingsResponse( + customer_id=result.customer_id, + as_of_date=result.as_of_date, + completeness=result.completeness, + total_value=result.total_value, + holdings=[_holding_to_response(uow, line) for line in result.holdings], + ) + + return jsonify(view.model_dump(mode="json")), 200 + + +def _holding_to_response(uow: RebalanceUnitOfWork, line: HoldingLine) -> HoldingResponse: + return HoldingResponse( + security_id=line.security_id, + symbol=_symbol_for(uow, line.security_id) if line.security_id is not None else None, + units=line.units, + price=line.price, + market_value=line.market_value, + current_weight_pct=line.current_weight_pct, + target_weight_pct=line.target_weight_pct, + drift_pct=line.drift_pct, + is_flagged=line.is_flagged, + ) + + +@portfolios_bp.route("/performance", methods=["GET"]) +@limiter.limit("60 per minute") +@requires_role("customer", *_STAFF_ROLES) +def performance() -> Any: + """Live, as-of-now performance series (ADR 26) -- `GET /api/v1/statements` remains the + as-published series. `range` is an allowlisted enum, never a free-form date pair.""" + customer_id = _resolve_customer_id() + try: + query = _PerformanceQuery.model_validate(request.args.to_dict()) + except PydanticValidationError as exc: + raise ValidationError(str(exc), code="invalid_range") from exc + + with ValuationUnitOfWork( + customer_id=_uow_customer_id(customer_id), role=_session_role() + ) as uow: + clock = MarketClock(CachedTradingCalendar(uow.calendar_cache)) + today = clock.market_date(datetime.now(UTC)) + result = PortfolioPerformanceService(uow).performance( + customer_id, performance_range=query.range, as_of=Watermark.live(), today=today + ) + + view = PortfolioPerformanceResponse( + customer_id=result.customer_id, + range=result.range, + period_start=result.period_start, + period_end=result.period_end, + cumulative_twr=result.cumulative_twr, + is_provisional=result.is_provisional, + points=[ + PerformancePointResponse(as_of_date=point.as_of_date, value=point.value) + for point in result.points + ], + ) + return jsonify(view.model_dump(mode="json")), 200 + + __all__ = ["portfolios_bp"] diff --git a/backend/app/models/marketdata/sub_period_return.py b/backend/app/models/marketdata/sub_period_return.py index 82af8a0..6f6e585 100644 --- a/backend/app/models/marketdata/sub_period_return.py +++ b/backend/app/models/marketdata/sub_period_return.py @@ -104,6 +104,44 @@ def as_of_for_sub_period( .first() ) + def list_in_range( + self, + *, + customer_id: uuid.UUID, + start_date: date | None, + end_date: date, + as_of: Watermark, + ) -> list[SubPeriodReturn]: + """Every latest-`recorded_at` stored row whose `[sub_period_start, sub_period_end]` window + overlaps `[start_date, end_date]` (`start_date=None` means unbounded -- the portfolio + performance endpoint's `all` range, ADR 26), visible as of `as_of` (S6 §6/§7's watermark + convention, applied to a live read via `Watermark.live()`). Ordered ascending by + `sub_period_start` -- the order `PortfolioPerformanceService` folds into points and + `TwrService.link` expects for re-linking.""" + conditions = [ + SubPeriodReturn.customer_id == customer_id, + SubPeriodReturn.sub_period_start <= end_date, + SubPeriodReturn.recorded_at <= as_of.cutoff, + ] + if start_date is not None: + conditions.append(SubPeriodReturn.sub_period_end >= start_date) + statement = ( + select(SubPeriodReturn) + .where(*conditions) + .order_by( + SubPeriodReturn.sub_period_start, + SubPeriodReturn.sub_period_end, + SubPeriodReturn.recorded_at.desc(), + ) + ) + rows = self.session.execute(statement).scalars().all() + latest_by_window: dict[tuple[date, date], SubPeriodReturn] = {} + for row in rows: + latest_by_window.setdefault((row.sub_period_start, row.sub_period_end), row) + return sorted( + latest_by_window.values(), key=lambda row: (row.sub_period_start, row.sub_period_end) + ) + def containing(self, *, customer_id: uuid.UUID, on_date: date) -> list[SubPeriodReturn]: """The latest-`recorded_at` row for every distinct stored window containing `on_date` (S6 §5).""" statement = ( diff --git a/backend/app/services/rebalance/portfolio_holdings_service.py b/backend/app/services/rebalance/portfolio_holdings_service.py new file mode 100644 index 0000000..cc0ce6c --- /dev/null +++ b/backend/app/services/rebalance/portfolio_holdings_service.py @@ -0,0 +1,89 @@ +"""`PortfolioHoldingsService` (Portfolio page redesign, ADR 26 context) — a thin per-security +holdings read model over `DriftEvaluationService.evaluate()` (S9 §4). + +Today `DriftEvaluationService` is only reachable via the adviser/admin-only +`GET /api/v1/admin/rebalance/` route (`app/controllers/admin/rebalance.py`). This +service exposes the exact same live evaluation to the customer who owns the holdings, in the shape +`GET /api/v1/portfolios/holdings` needs -- it adds no new valuation logic, it only reshapes +`DriftEvaluation`'s entries into a per-security line (`units`, derived from `Money / Price -> +Units`, ADR 16) and carries `completeness` through unchanged, matching `ValuationService`/ +`DriftEvaluationService`'s own discipline: a partial evaluation is never presented as "you hold +nothing" (S4 §9, NFR-6). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from app.services.rebalance.drift_evaluation_service import DriftEvaluationService + +if TYPE_CHECKING: + import uuid + from datetime import date + from decimal import Decimal + + from app.core.money import Money, Price, Units + from app.services.rebalance.drift_evaluation_service import DriftEntry + from app.services.rebalance.uow import RebalanceUnitOfWork + from app.services.valuation.valuation_service import Completeness + + +@dataclass(frozen=True, slots=True) +class HoldingLine: + """One held-or-targeted security; `security_id is None` is the implicit CASH line + (`DriftEntry`'s own convention). A security dropped from the model still appears here with + `target_weight_pct == 0` -- `DriftEvaluationService.evaluate()` already guarantees this, this + service only reshapes it.""" + + security_id: uuid.UUID | None + units: Units | None + price: Price | None + market_value: Money + current_weight_pct: Decimal + target_weight_pct: Decimal + drift_pct: Decimal + is_flagged: bool + + +@dataclass(frozen=True, slots=True) +class PortfolioHoldings: + customer_id: uuid.UUID + as_of_date: date + completeness: Completeness + total_value: Money + holdings: tuple[HoldingLine, ...] + + +class PortfolioHoldingsService: + def __init__(self, uow: RebalanceUnitOfWork, *, drift_band_pct: Decimal) -> None: + self._drift = DriftEvaluationService(uow, drift_band_pct=drift_band_pct) + + def holdings(self, customer_id: uuid.UUID, as_of_date: date) -> PortfolioHoldings: + evaluation = self._drift.evaluate(customer_id, as_of_date) + return PortfolioHoldings( + customer_id=evaluation.customer_id, + as_of_date=evaluation.as_of_date, + completeness=evaluation.completeness, + total_value=evaluation.total_value, + holdings=tuple(self._to_line(entry) for entry in evaluation.entries), + ) + + @staticmethod + def _to_line(entry: DriftEntry) -> HoldingLine: + units = ( + entry.current_market_value / entry.price if entry.price is not None else None + ) + return HoldingLine( + security_id=entry.security_id, + units=units, + price=entry.price, + market_value=entry.current_market_value, + current_weight_pct=entry.current_weight_pct, + target_weight_pct=entry.target_weight_pct, + drift_pct=entry.current_weight_pct - entry.target_weight_pct, + is_flagged=entry.is_flagged, + ) + + +__all__ = ["HoldingLine", "PortfolioHoldings", "PortfolioHoldingsService"] diff --git a/backend/app/services/valuation/portfolio_performance_service.py b/backend/app/services/valuation/portfolio_performance_service.py new file mode 100644 index 0000000..a3092f0 --- /dev/null +++ b/backend/app/services/valuation/portfolio_performance_service.py @@ -0,0 +1,118 @@ +"""`PortfolioPerformanceService` (ADR 26) — folds stored `sub_period_return` rows into an ordered +`[{date, value}]` series plus a cumulative time-weighted return, for the Portfolio page's +performance chart. + +This is the live/as-of-now view (`Watermark.live()`, never an ad-hoc `datetime.now()` inlined +here). `GET /api/v1/statements` remains the authoritative *as-published* series (ADR 6) -- this +service never reads `published_snapshot` and never substitutes for it. `range` is a required +keyword argument with no default (ADR 6/14's no-silent-default convention for a bitemporal-adjacent +read); only the controller may default the raw query parameter before calling in. +""" + +from __future__ import annotations + +import calendar +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from app.services.valuation.twr_service import TwrService + +if TYPE_CHECKING: + import uuid + from collections.abc import Sequence + from datetime import date + from decimal import Decimal + + from app.core.money import Money + from app.core.watermark import Watermark + from app.models.marketdata.sub_period_return import SubPeriodReturn + from app.services.valuation.uow import ValuationUnitOfWork + +PerformanceRange = Literal["1m", "3m", "6m", "1y", "all"] + +_RANGE_MONTHS: dict[str, int] = {"1m": 1, "3m": 3, "6m": 6, "1y": 12} + + +@dataclass(frozen=True, slots=True) +class PerformancePoint: + as_of_date: date + value: Money + + +@dataclass(frozen=True, slots=True) +class PortfolioPerformance: + customer_id: uuid.UUID + range: PerformanceRange + period_start: date | None + """`None` when no `sub_period_return` coverage exists in the requested range yet (ADR 26) -- + an empty, not-yet-computed series, not an error.""" + period_end: date + cumulative_twr: Decimal + is_provisional: bool + points: tuple[PerformancePoint, ...] + + +class PortfolioPerformanceService: + def __init__(self, uow: ValuationUnitOfWork) -> None: + self._uow = uow + + def performance( + self, + customer_id: uuid.UUID, + *, + performance_range: PerformanceRange, + as_of: Watermark, + today: date, + ) -> PortfolioPerformance: + start_date = self._range_start(performance_range, today) + sub_periods = self._uow.sub_period_returns.list_in_range( + customer_id=customer_id, start_date=start_date, end_date=today, as_of=as_of + ) + return PortfolioPerformance( + customer_id=customer_id, + range=performance_range, + period_start=sub_periods[0].sub_period_start if sub_periods else None, + period_end=today, + cumulative_twr=TwrService.link(sub_periods), + is_provisional=any(row.is_provisional for row in sub_periods), + points=self._to_points(sub_periods), + ) + + @staticmethod + def _to_points(sub_periods: Sequence[SubPeriodReturn]) -> tuple[PerformancePoint, ...]: + if not sub_periods: + return () + points = [ + PerformancePoint( + as_of_date=sub_periods[0].sub_period_start, value=sub_periods[0].value_begin + ) + ] + points.extend( + PerformancePoint(as_of_date=row.sub_period_end, value=row.value_end) + for row in sub_periods + ) + return tuple(points) + + @staticmethod + def _range_start(performance_range: PerformanceRange, today: date) -> date | None: + if performance_range == "all": + return None + return _subtract_months(today, _RANGE_MONTHS[performance_range]) + + +def _subtract_months(on_date: date, months: int) -> date: + """Calendar-month subtraction, clamping the day to the target month's length (e.g. Mar 31 + minus 1 month -> Feb 28/29) -- stdlib only, no `dateutil` dependency in this codebase.""" + month_index = on_date.month - 1 - months + year = on_date.year + month_index // 12 + month = month_index % 12 + 1 + day = min(on_date.day, calendar.monthrange(year, month)[1]) + return on_date.replace(year=year, month=month, day=day) + + +__all__ = [ + "PerformancePoint", + "PerformanceRange", + "PortfolioPerformance", + "PortfolioPerformanceService", +] diff --git a/backend/app/views/portfolios.py b/backend/app/views/portfolios.py index b5e6394..d7ac8c1 100644 --- a/backend/app/views/portfolios.py +++ b/backend/app/views/portfolios.py @@ -9,9 +9,12 @@ import uuid # noqa: TC003 -- Pydantic resolves field annotations at class-build time. from datetime import date # noqa: TC003 from decimal import Decimal # noqa: TC003 +from typing import Literal from pydantic import BaseModel, field_serializer +from app.core.money import Money, Price, Units # noqa: TC001 -- Pydantic resolves eagerly. + class TargetWeightResponse(BaseModel): security_id: uuid.UUID @@ -55,10 +58,73 @@ class CurrentAssignmentResponse(BaseModel): assignment: AssignmentResponse | None +class HoldingResponse(BaseModel): + """One line of `GET /api/v1/portfolios/holdings` (Portfolio page redesign, ADR 26 context) -- + `security_id`/`symbol`/`units`/`price` are all `None` for the implicit CASH line + (`DriftEntry`'s own convention, S9 §4). A security the customer holds but that has dropped out + of their assigned model still appears here with `target_weight_pct: 0`.""" + + security_id: uuid.UUID | None + symbol: str | None + units: Units | None + price: Price | None + market_value: Money + current_weight_pct: Decimal + target_weight_pct: Decimal + drift_pct: Decimal + is_flagged: bool + + @field_serializer("current_weight_pct", "target_weight_pct", "drift_pct", when_used="json") + def _serialize_pct(self, value: Decimal) -> str: + """Plain fixed-point, matching `TargetWeightResponse`'s own precedent for a ratio field.""" + return format(value, "f") + + +class PortfolioHoldingsResponse(BaseModel): + """`GET /api/v1/portfolios/holdings` -- live, as-of-today holdings against the customer's + assigned model. `completeness` must survive to the wire unchanged (NFR-6): `"partial"` is + never presented as an empty holdings list.""" + + customer_id: uuid.UUID + as_of_date: date + completeness: Literal["complete", "partial"] + total_value: Money + holdings: list[HoldingResponse] + + +class PerformancePointResponse(BaseModel): + as_of_date: date + value: Money + + +class PortfolioPerformanceResponse(BaseModel): + """`GET /api/v1/portfolios/performance` (ADR 26) -- the live, as-of-now performance series. + Always `watermark_type: 'live'`; `GET /api/v1/statements`'s `StatementDetailResponse` carries + the as-published counterpart and is never confused with this one.""" + + customer_id: uuid.UUID + range: Literal["1m", "3m", "6m", "1y", "all"] + period_start: date | None + period_end: date + cumulative_twr: Decimal + is_provisional: bool + points: list[PerformancePointResponse] + watermark_type: Literal["live"] = "live" + + @field_serializer("cumulative_twr", when_used="json") + def _serialize_twr(self, value: Decimal) -> str: + """Plain fixed-point, matching `ReturnsResponse.twr`'s identical precedent.""" + return format(value, "f") + + __all__ = [ "AssignmentResponse", "CurrentAssignmentResponse", + "HoldingResponse", "ModelPortfolioListResponse", "ModelPortfolioResponse", + "PerformancePointResponse", + "PortfolioHoldingsResponse", + "PortfolioPerformanceResponse", "TargetWeightResponse", ] diff --git a/backend/tests/api/test_portfolios_holdings.py b/backend/tests/api/test_portfolios_holdings.py new file mode 100644 index 0000000..3a7e39c --- /dev/null +++ b/backend/tests/api/test_portfolios_holdings.py @@ -0,0 +1,338 @@ +"""`GET /api/v1/portfolios/holdings` (Portfolio page redesign, ADR 26 context) via the Flask test +client -- the customer-facing counterpart to `tests/api/test_admin_rebalance_api.py`'s adviser-only +route, built on the same `DriftEvaluationService` (S9 §4). +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from datetime import UTC, 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.clock import MARKET_TIMEZONE +from app.core.money import Money, Price, Units +from app.models.identity.customer import AccountApprovalStatus, Customer, KycStatus +from app.models.ledger.account import Account, AccountRole +from app.models.ledger.customer_cash_lock import CustomerCashLock +from app.models.ledger.journal_entry import JournalEntry, JournalEntryType +from app.models.ledger.posting import Posting +from app.models.ledger.settlement_obligation import SettlementObligation +from app.models.marketdata.daily_close import DailyClose, DailyCloseStatus, MarketDataSource +from app.models.marketdata.security import Security, SecurityAssetClass +from app.models.ops.inbound_event import InboundEvent, InboundEventSource +from app.models.rebalance.customer_model_assignment import CustomerModelAssignment +from app.models.rebalance.model_portfolio import ModelPortfolio +from app.models.rebalance.target_weight import TargetWeight +from app.services.ledger.posting_service import PostingLeg, PostingService + +CUSTOMER_EMAIL = "holdings-customer@trueup.example" +CUSTOMER_EMAIL_2 = "holdings-customer-2@trueup.example" +CUSTOMER_PASSWORD = "correct-horse-battery" +MARKET_DATE = datetime.now(UTC).astimezone(MARKET_TIMEZONE).date() +"""Matches `MarketClock.market_date()`'s own computation; the controller has no injectable clock.""" + +HOLDINGS_TABLES = [ + InboundEvent.__table__, + Account.__table__, + JournalEntry.__table__, + Posting.__table__, + SettlementObligation.__table__, + CustomerCashLock.__table__, + Security.__table__, + DailyClose.__table__, + ModelPortfolio.__table__, + TargetWeight.__table__, + CustomerModelAssignment.__table__, +] + + +@pytest.fixture(autouse=True) +def _holdings_tables(owner_engine: Engine) -> Iterator[None]: + for table in HOLDINGS_TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield None + with owner_engine.begin() as connection: + for table in reversed(HOLDINGS_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}"')) + + +class _LedgerLikeUow: + def __init__(self, session: Session) -> None: + self.session = session + + class _Repo: + def __init__(self, session: Session) -> None: + self._session = session + + def add(self, obj: object) -> None: + self._session.add(obj) + + self.journal_entries = _Repo(session) + self.postings = _Repo(session) + + +def _register(client: FlaskClient, *, email: str, password: str) -> uuid.UUID: + response = client.post("/api/v1/auth/register", json={"email": email, "password": password}) + assert response.status_code == 201 + return uuid.UUID(response.get_json()["id"]) + + +def _login(client: FlaskClient, *, email: str, password: str) -> str: + response = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert response.status_code == 200 + csrf_token: str = response.get_json()["csrf_token"] + return csrf_token + + +def _post(session: Session, *, entry_type: JournalEntryType, legs: list[PostingLeg]) -> None: + 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=entry_type, effective_date=MARKET_DATE, source_event_id=event.id, legs=legs + ) + + +def _seed_customer_with_holding( + owner_engine: Engine, + customer_id: uuid.UUID, + *, + seed_close: bool = True, + symbol: str = "ONTGT", +) -> tuple[uuid.UUID, uuid.UUID]: + """100 shares of one security, deposit fully spent -- on-target for a 100% model.""" + session = Session(bind=owner_engine, expire_on_commit=False) + try: + customer = session.get(Customer, customer_id) + assert customer is not None + customer.kyc_status = KycStatus.approved + customer.account_approval_status = AccountApprovalStatus.approved + session.add(CustomerCashLock(customer_id=customer_id)) + + security = Security( + symbol=symbol, name=f"{symbol} Co.", asset_class=SecurityAssetClass.EQUITY + ) + session.add(security) + session.flush() + if seed_close: + session.add( + DailyClose( + security_id=security.id, + market_date=MARKET_DATE, + close_price=Price("100.00"), + source=MarketDataSource.LIVE, + status=DailyCloseStatus.CONFIRMED, + ) + ) + + cash = Account.create(AccountRole.CASH, customer_id=customer_id) + equity = Account.create(AccountRole.CUSTOMER_EQUITY, customer_id=customer_id) + units = Account.create( + AccountRole.POSITION_UNITS, customer_id=customer_id, security_id=security.id + ) + cost = Account.create( + AccountRole.POSITION_COST, customer_id=customer_id, security_id=security.id + ) + session.add_all([cash, equity, units, cost]) + session.flush() + + _post( + session, + entry_type=JournalEntryType.DEPOSIT, + legs=[ + PostingLeg(account_id=cash.id, amount_money=Money("10000.00")), + PostingLeg(account_id=equity.id, amount_money=Money("-10000.00")), + ], + ) + _post( + session, + entry_type=JournalEntryType.TRADE_BUY, + legs=[ + PostingLeg(account_id=units.id, quantity_units=Units("100")), + PostingLeg(account_id=cost.id, amount_money=Money("10000.00")), + PostingLeg(account_id=cash.id, amount_money=Money("-10000.00")), + ], + ) + + session.commit() + return security.id, cash.id + finally: + session.close() + + +def _assign_model( + owner_engine: Engine, + customer_id: uuid.UUID, + *, + target_security_id: uuid.UUID, + weight_pct: Decimal = Decimal("1.0000"), +) -> uuid.UUID: + session = Session(bind=owner_engine, expire_on_commit=False) + try: + model = ModelPortfolio(name="Fully Invested") + session.add(model) + session.flush() + session.add( + TargetWeight( + model_portfolio_id=model.id, + security_id=target_security_id, + weight_pct=weight_pct, + ) + ) + session.add( + CustomerModelAssignment( + customer_id=customer_id, model_portfolio_id=model.id, assigned_at=MARKET_DATE + ) + ) + session.commit() + return model.id + finally: + session.close() + + +# --- GET /portfolios/holdings ---------------------------------------------------------------- + + +def test_requires_authentication(api_client: FlaskClient) -> None: + response = api_client.get("/api/v1/portfolios/holdings") + + assert response.status_code == 401 + + +def test_happy_path_returns_holdings_with_symbol_and_completeness( + 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) + security_id, _cash_id = _seed_customer_with_holding(owner_engine, customer_id) + model_id = _assign_model(owner_engine, customer_id, target_security_id=security_id) + + response = api_client.get("/api/v1/portfolios/holdings") + + assert response.status_code == 200 + body = response.get_json() + assert body["customer_id"] == str(customer_id) + assert body["completeness"] == "complete" + assert body["total_value"] == "10000.0000" + holdings_by_security = {h["security_id"]: h for h in body["holdings"]} + security_line = holdings_by_security[str(security_id)] + assert security_line["symbol"] == "ONTGT" + assert security_line["units"] == "100.000000" + assert security_line["price"] == "100.000000" + assert security_line["market_value"] == "10000.0000" + assert Decimal(security_line["target_weight_pct"]) == Decimal("1.0000") + assert Decimal(security_line["drift_pct"]) == Decimal("0.0000") + assert security_line["is_flagged"] is False + cash_line = holdings_by_security[None] + assert cash_line["symbol"] is None + assert cash_line["units"] is None + assert cash_line["price"] is None + del model_id + + +def test_no_assigned_model_is_a_distinguishable_404(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/portfolios/holdings") + + assert response.status_code == 404 + assert response.get_json()["code"] == "no_model_assigned" + + +def test_partial_completeness_is_reported_explicitly_not_as_an_empty_list( + 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) + # No confirmed close for the held security -- valuation is partial (S4 §4). + security_id, _cash_id = _seed_customer_with_holding(owner_engine, customer_id, seed_close=False) + _assign_model(owner_engine, customer_id, target_security_id=security_id) + + response = api_client.get("/api/v1/portfolios/holdings") + + assert response.status_code == 200 + body = response.get_json() + assert body["completeness"] == "partial" + assert body["holdings"] == [] + + +def test_security_dropped_from_model_still_appears_with_zero_target_weight( + 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) + held_security_id, _cash_id = _seed_customer_with_holding( + owner_engine, customer_id, symbol="DROPPED" + ) + # Target a different security the customer holds none of -- `held_security_id` is fully + # dropped from the model, but must still surface (S9 §8 item 4). + session = Session(bind=owner_engine, expire_on_commit=False) + other_security = Security( + symbol="TARGETONLY", name="Target Only Co.", asset_class=SecurityAssetClass.EQUITY + ) + session.add(other_security) + session.flush() + session.add( + DailyClose( + security_id=other_security.id, + market_date=MARKET_DATE, + close_price=Price("50.00"), + source=MarketDataSource.LIVE, + status=DailyCloseStatus.CONFIRMED, + ) + ) + session.commit() + other_security_id = other_security.id + session.close() + _assign_model(owner_engine, customer_id, target_security_id=other_security_id) + + response = api_client.get("/api/v1/portfolios/holdings") + + assert response.status_code == 200 + body = response.get_json() + holdings_by_security = {h["security_id"]: h for h in body["holdings"]} + dropped_line = holdings_by_security[str(held_security_id)] + assert Decimal(dropped_line["target_weight_pct"]) == Decimal("0") + assert dropped_line["market_value"] == "10000.0000" + + +def test_another_customers_holdings_are_isolated( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id = _register(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + security_id, _cash_id = _seed_customer_with_holding(owner_engine, customer_id, symbol="ALPHA") + _assign_model(owner_engine, customer_id, target_security_id=security_id) + + _register(api_client, email=CUSTOMER_EMAIL_2, password=CUSTOMER_PASSWORD) + _login(api_client, email=CUSTOMER_EMAIL_2, password=CUSTOMER_PASSWORD) + + response = api_client.get("/api/v1/portfolios/holdings") + + # The second customer has no assignment of their own -- never customer one's data. + assert response.status_code == 404 + assert response.get_json()["code"] == "no_model_assigned" + + +def test_is_throttled(api_client: FlaskClient) -> None: + _register(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + _login(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + + last_response = None + for _ in range(61): + last_response = api_client.get("/api/v1/portfolios/holdings") + + 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_portfolios_performance.py b/backend/tests/api/test_portfolios_performance.py new file mode 100644 index 0000000..0fe7684 --- /dev/null +++ b/backend/tests/api/test_portfolios_performance.py @@ -0,0 +1,214 @@ +"""`GET /api/v1/portfolios/performance` (ADR 26) via the Flask test client -- folds stored +`sub_period_return` rows into an ordered `[{date, value}]` series plus a cumulative TWR. Always the +live series; `tests/api/test_statements_api.py` covers the as-published counterpart separately. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest +from flask.testing import FlaskClient +from sqlalchemy import Engine, text +from sqlalchemy.orm import Session + +from app.core.clock import MARKET_TIMEZONE +from app.core.money import Money +from app.models.marketdata.sub_period_return import SubPeriodReturn + +CUSTOMER_EMAIL = "performance-customer@trueup.example" +CUSTOMER_EMAIL_2 = "performance-customer-2@trueup.example" +CUSTOMER_PASSWORD = "correct-horse-battery" +TODAY = datetime.now(UTC).astimezone(MARKET_TIMEZONE).date() +"""Matches `MarketClock.market_date()`'s own computation; the controller has no injectable clock.""" + +PERFORMANCE_TABLES = [SubPeriodReturn.__table__] + + +@pytest.fixture(autouse=True) +def _performance_tables(owner_engine: Engine) -> Iterator[None]: + for table in PERFORMANCE_TABLES: + table.create(bind=owner_engine, checkfirst=True) + yield None + with owner_engine.begin() as connection: + for table in reversed(PERFORMANCE_TABLES): + connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}"')) + + +def _register(client: FlaskClient, *, email: str, password: str) -> uuid.UUID: + response = client.post("/api/v1/auth/register", json={"email": email, "password": password}) + assert response.status_code == 201 + return uuid.UUID(response.get_json()["id"]) + + +def _login(client: FlaskClient, *, email: str, password: str) -> str: + response = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert response.status_code == 200 + csrf_token: str = response.get_json()["csrf_token"] + return csrf_token + + +def _recent_sub_period(owner_engine: Engine, customer_id: uuid.UUID) -> None: + """One row inside every allowlisted range: `[today - 10d, today]`, +5% return.""" + session = Session(bind=owner_engine, expire_on_commit=False) + session.add( + SubPeriodReturn( + customer_id=customer_id, + sub_period_start=TODAY - timedelta(days=10), + sub_period_end=TODAY, + return_pct=Decimal("0.0500000000"), + value_begin=Money("10000.0000"), + value_end=Money("10500.0000"), + flow_amount=Money("0.0000"), + is_provisional=False, + ) + ) + session.commit() + session.close() + + +def _old_sub_period(owner_engine: Engine, customer_id: uuid.UUID) -> None: + """A second row well outside every range but `all`: `[today - 200d, today - 190d]`.""" + session = Session(bind=owner_engine, expire_on_commit=False) + session.add( + SubPeriodReturn( + customer_id=customer_id, + sub_period_start=TODAY - timedelta(days=200), + sub_period_end=TODAY - timedelta(days=190), + return_pct=Decimal("0.0200000000"), + value_begin=Money("9000.0000"), + value_end=Money("9180.0000"), + flow_amount=Money("0.0000"), + is_provisional=False, + ) + ) + session.commit() + session.close() + + +# --- GET /portfolios/performance ------------------------------------------------------------- + + +def test_requires_authentication(api_client: FlaskClient) -> None: + response = api_client.get("/api/v1/portfolios/performance") + + assert response.status_code == 401 + + +def test_default_range_folds_the_stored_sub_period_into_points( + 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) + _recent_sub_period(owner_engine, customer_id) + + response = api_client.get("/api/v1/portfolios/performance") + + assert response.status_code == 200 + body = response.get_json() + assert body["customer_id"] == str(customer_id) + assert body["range"] == "1m" + assert body["period_start"] == (TODAY - timedelta(days=10)).isoformat() + assert body["period_end"] == TODAY.isoformat() + assert body["cumulative_twr"] == "0.0500000000" + assert body["is_provisional"] is False + assert body["watermark_type"] == "live" + assert body["points"] == [ + {"as_of_date": (TODAY - timedelta(days=10)).isoformat(), "value": "10000.0000"}, + {"as_of_date": TODAY.isoformat(), "value": "10500.0000"}, + ] + + +def test_1m_range_excludes_a_sub_period_outside_the_window( + 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) + _recent_sub_period(owner_engine, customer_id) + _old_sub_period(owner_engine, customer_id) + + response = api_client.get("/api/v1/portfolios/performance?range=1m") + + assert response.status_code == 200 + body = response.get_json() + assert body["period_start"] == (TODAY - timedelta(days=10)).isoformat() + assert body["cumulative_twr"] == "0.0500000000" + assert len(body["points"]) == 2 + + +def test_all_range_includes_every_stored_sub_period( + 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) + _recent_sub_period(owner_engine, customer_id) + _old_sub_period(owner_engine, customer_id) + + response = api_client.get("/api/v1/portfolios/performance?range=all") + + assert response.status_code == 200 + body = response.get_json() + assert body["period_start"] == (TODAY - timedelta(days=200)).isoformat() + # (1.02 * 1.05) - 1, quantized to sub_period_return.return_pct's NUMERIC(18,10) scale. + assert body["cumulative_twr"] == "0.0710000000" + assert body["points"] == [ + {"as_of_date": (TODAY - timedelta(days=200)).isoformat(), "value": "9000.0000"}, + {"as_of_date": (TODAY - timedelta(days=190)).isoformat(), "value": "9180.0000"}, + {"as_of_date": TODAY.isoformat(), "value": "10500.0000"}, + ] + + +def test_no_stored_coverage_is_an_empty_series_not_an_error(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/portfolios/performance?range=1y") + + assert response.status_code == 200 + body = response.get_json() + assert body["period_start"] is None + assert body["cumulative_twr"] == "0.0000000000" + assert body["points"] == [] + + +def test_invalid_range_is_a_validation_error(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/portfolios/performance?range=2y") + + assert response.status_code == 422 + assert response.get_json()["code"] == "invalid_range" + + +def test_another_customers_performance_is_isolated( + api_client: FlaskClient, owner_engine: Engine +) -> None: + customer_id = _register(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + _recent_sub_period(owner_engine, customer_id) + + _register(api_client, email=CUSTOMER_EMAIL_2, password=CUSTOMER_PASSWORD) + _login(api_client, email=CUSTOMER_EMAIL_2, password=CUSTOMER_PASSWORD) + + response = api_client.get("/api/v1/portfolios/performance") + + assert response.status_code == 200 + body = response.get_json() + assert body["points"] == [] + assert body["cumulative_twr"] == "0.0000000000" + + +def test_is_throttled(api_client: FlaskClient) -> None: + _register(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + _login(api_client, email=CUSTOMER_EMAIL, password=CUSTOMER_PASSWORD) + + last_response = None + for _ in range(61): + last_response = api_client.get("/api/v1/portfolios/performance") + + assert last_response is not None + assert last_response.status_code == 429 + assert "Retry-After" in last_response.headers diff --git a/backend/tests/integration/test_portfolio_holdings_service.py b/backend/tests/integration/test_portfolio_holdings_service.py new file mode 100644 index 0000000..2439eec --- /dev/null +++ b/backend/tests/integration/test_portfolio_holdings_service.py @@ -0,0 +1,274 @@ +"""`PortfolioHoldingsService` (Portfolio page redesign, ADR 26 context) against real Postgres -- +exercises the same `DriftEvaluationService` + RLS-scoped `RebalanceUnitOfWork` wiring +`GET /api/v1/portfolios/holdings` uses. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from datetime import date +from decimal import Decimal + +import pytest +from sqlalchemy import Engine, text +from sqlalchemy.orm import Session + +from app.config import Settings +from app.core.db import DbRole +from app.core.money import Money, Price, Units +from app.core.uow import SessionRole +from app.extensions import dispose_engines, init_engines +from app.models.identity.customer import AccountApprovalStatus, Customer, KycStatus +from app.models.ledger.account import Account, AccountRole +from app.models.ledger.customer_cash_lock import CustomerCashLock +from app.models.ledger.journal_entry import JournalEntry, JournalEntryType +from app.models.ledger.posting import Posting +from app.models.ledger.settlement_obligation import SettlementObligation +from app.models.marketdata.daily_close import DailyClose, DailyCloseStatus, MarketDataSource +from app.models.marketdata.security import Security, SecurityAssetClass +from app.models.ops.inbound_event import InboundEvent, InboundEventSource +from app.models.rebalance.customer_model_assignment import CustomerModelAssignment +from app.models.rebalance.model_portfolio import ModelPortfolio +from app.models.rebalance.target_weight import TargetWeight +from app.services.ledger.posting_service import PostingLeg, PostingService +from app.services.rebalance.drift_evaluation_service import NoAssignedModelError +from app.services.rebalance.portfolio_holdings_service import PortfolioHoldingsService +from app.services.rebalance.uow import RebalanceUnitOfWork + +MARKET_DATE = date(2026, 9, 5) +DRIFT_BAND_PCT = Decimal("0.05") + +_TABLES = [ + Customer.__table__, + InboundEvent.__table__, + Account.__table__, + JournalEntry.__table__, + Posting.__table__, + SettlementObligation.__table__, + CustomerCashLock.__table__, + Security.__table__, + DailyClose.__table__, + ModelPortfolio.__table__, + TargetWeight.__table__, + CustomerModelAssignment.__table__, +] + +pytestmark = pytest.mark.usefixtures("_tables") + + +class _LedgerLikeUow: + def __init__(self, session: Session) -> None: + self.session = session + + class _Repo: + def __init__(self, session: Session) -> None: + self._session = session + + def add(self, obj: object) -> None: + self._session.add(obj) + + self.journal_entries = _Repo(session) + self.postings = _Repo(session) + + +@pytest.fixture(autouse=True) +def _engines(test_settings: Settings) -> Iterator[None]: + init_engines(test_settings) + yield None + dispose_engines() + + +@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')) + + +def _seed_customer(db_committing: Session) -> uuid.UUID: + customer = Customer( + email=f"{uuid.uuid4()}@trueup.test", + password_hash="hash", + kyc_status=KycStatus.approved, + account_approval_status=AccountApprovalStatus.approved, + ) + db_committing.add(customer) + db_committing.flush() + db_committing.add(CustomerCashLock(customer_id=customer.id)) + db_committing.commit() + return customer.id + + +def _insert_event(db_committing: Session) -> uuid.UUID: + event = InboundEvent( + source=InboundEventSource.CUSTODIAN_FILE, + source_event_id=str(uuid.uuid4()), + payload={}, + signature_verified=True, + ) + db_committing.add(event) + db_committing.flush() + return event.id + + +def _holdings(customer_id: uuid.UUID, *, as_of_date: date = MARKET_DATE): + with RebalanceUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + return PortfolioHoldingsService(uow, drift_band_pct=DRIFT_BAND_PCT).holdings( + customer_id, as_of_date + ) + + +def test_raises_when_no_model_assigned(db_committing: Session) -> None: + customer_id = _seed_customer(db_committing) + + with pytest.raises(NoAssignedModelError): + _holdings(customer_id) + + +def test_holdings_include_units_price_and_drift(db_committing: Session) -> None: + customer_id = _seed_customer(db_committing) + security = Security( + symbol="ITG", name="Integration Co.", asset_class=SecurityAssetClass.EQUITY + ) + db_committing.add(security) + db_committing.flush() + db_committing.add( + DailyClose( + security_id=security.id, + market_date=MARKET_DATE, + close_price=Price("100.00"), + source=MarketDataSource.LIVE, + status=DailyCloseStatus.CONFIRMED, + ) + ) + cash = Account.create(AccountRole.CASH, customer_id=customer_id) + equity = Account.create(AccountRole.CUSTOMER_EQUITY, customer_id=customer_id) + units = Account.create( + AccountRole.POSITION_UNITS, customer_id=customer_id, security_id=security.id + ) + cost = Account.create( + AccountRole.POSITION_COST, customer_id=customer_id, security_id=security.id + ) + db_committing.add_all([cash, equity, units, cost]) + db_committing.flush() + + posting = PostingService(_LedgerLikeUow(db_committing)) + posting.post( + entry_type=JournalEntryType.DEPOSIT, + effective_date=MARKET_DATE, + source_event_id=_insert_event(db_committing), + legs=[ + PostingLeg(account_id=cash.id, amount_money=Money("10000.00")), + PostingLeg(account_id=equity.id, amount_money=Money("-10000.00")), + ], + ) + posting.post( + entry_type=JournalEntryType.TRADE_BUY, + effective_date=MARKET_DATE, + source_event_id=_insert_event(db_committing), + legs=[ + PostingLeg(account_id=units.id, quantity_units=Units("100")), + PostingLeg(account_id=cost.id, amount_money=Money("10000.00")), + PostingLeg(account_id=cash.id, amount_money=Money("-10000.00")), + ], + ) + + model = ModelPortfolio(name="Test Model") + db_committing.add(model) + db_committing.flush() + db_committing.add( + TargetWeight( + model_portfolio_id=model.id, security_id=security.id, weight_pct=Decimal("1.0000") + ) + ) + db_committing.add( + CustomerModelAssignment( + customer_id=customer_id, model_portfolio_id=model.id, assigned_at=MARKET_DATE + ) + ) + db_committing.commit() + + result = _holdings(customer_id) + + assert result.completeness == "complete" + assert result.total_value == Money("10000.00") + by_security = {line.security_id: line for line in result.holdings} + security_line = by_security[security.id] + assert security_line.units == Units("100") + assert security_line.price == Price("100.00") + assert security_line.market_value == Money("10000.00") + assert security_line.target_weight_pct == Decimal("1.0000") + assert security_line.drift_pct == Decimal("0.0000") + assert security_line.is_flagged is False + cash_line = by_security[None] + assert cash_line.units is None + assert cash_line.price is None + + +def test_partial_valuation_yields_no_entries(db_committing: Session) -> None: + """A held security with no confirmed close (S4 §4) -- the whole evaluation is partial, and + `holdings` is empty, never a silent "you hold nothing" (NFR-6).""" + customer_id = _seed_customer(db_committing) + security = Security( + symbol="NOCLOSE", name="No Close Co.", asset_class=SecurityAssetClass.EQUITY + ) + db_committing.add(security) + db_committing.flush() + # Deliberately no DailyClose row for `security`. + cash = Account.create(AccountRole.CASH, customer_id=customer_id) + equity = Account.create(AccountRole.CUSTOMER_EQUITY, customer_id=customer_id) + units = Account.create( + AccountRole.POSITION_UNITS, customer_id=customer_id, security_id=security.id + ) + cost = Account.create( + AccountRole.POSITION_COST, customer_id=customer_id, security_id=security.id + ) + db_committing.add_all([cash, equity, units, cost]) + db_committing.flush() + + posting = PostingService(_LedgerLikeUow(db_committing)) + posting.post( + entry_type=JournalEntryType.DEPOSIT, + effective_date=MARKET_DATE, + source_event_id=_insert_event(db_committing), + legs=[ + PostingLeg(account_id=cash.id, amount_money=Money("10000.00")), + PostingLeg(account_id=equity.id, amount_money=Money("-10000.00")), + ], + ) + posting.post( + entry_type=JournalEntryType.TRADE_BUY, + effective_date=MARKET_DATE, + source_event_id=_insert_event(db_committing), + legs=[ + PostingLeg(account_id=units.id, quantity_units=Units("100")), + PostingLeg(account_id=cost.id, amount_money=Money("10000.00")), + PostingLeg(account_id=cash.id, amount_money=Money("-10000.00")), + ], + ) + + model = ModelPortfolio(name="Test Model") + db_committing.add(model) + db_committing.flush() + db_committing.add( + TargetWeight( + model_portfolio_id=model.id, security_id=security.id, weight_pct=Decimal("1.0000") + ) + ) + db_committing.add( + CustomerModelAssignment( + customer_id=customer_id, model_portfolio_id=model.id, assigned_at=MARKET_DATE + ) + ) + db_committing.commit() + + result = _holdings(customer_id) + + assert result.completeness == "partial" + assert result.holdings == () diff --git a/backend/tests/integration/test_portfolio_performance_service.py b/backend/tests/integration/test_portfolio_performance_service.py new file mode 100644 index 0000000..ef6b034 --- /dev/null +++ b/backend/tests/integration/test_portfolio_performance_service.py @@ -0,0 +1,238 @@ +"""`PortfolioPerformanceService` (ADR 26) against real Postgres -- exercises +`SubPeriodReturnRepository.list_in_range()`'s watermarked, tenant-scoped read end to end, the same +wiring `GET /api/v1/portfolios/performance` uses. +""" + +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 sqlalchemy.orm import Session + +from app.config import Settings +from app.core.db import DbRole +from app.core.money import Money +from app.core.uow import SessionRole +from app.core.watermark import Watermark +from app.extensions import dispose_engines, init_engines +from app.models.identity.customer import Customer +from app.models.marketdata.sub_period_return import SubPeriodReturn +from app.services.valuation.portfolio_performance_service import PortfolioPerformanceService +from app.services.valuation.uow import ValuationUnitOfWork + +TODAY = date(2026, 9, 5) + +_TABLES = [Customer.__table__, SubPeriodReturn.__table__] + +pytestmark = pytest.mark.usefixtures("_tables") + + +@pytest.fixture(autouse=True) +def _engines(test_settings: Settings) -> Iterator[None]: + init_engines(test_settings) + yield None + dispose_engines() + + +@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')) + + +def _seed_customer(db_committing: Session) -> uuid.UUID: + customer = Customer(email=f"{uuid.uuid4()}@trueup.test", password_hash="hash") + db_committing.add(customer) + db_committing.commit() + return customer.id + + +def _sub_period( + customer_id: uuid.UUID, + *, + start: date, + end: date, + return_pct: Decimal, + value_begin: Money, + value_end: Money, + is_provisional: bool = False, + recorded_at: datetime | None = None, +) -> SubPeriodReturn: + row = SubPeriodReturn( + customer_id=customer_id, + sub_period_start=start, + sub_period_end=end, + return_pct=return_pct, + value_begin=value_begin, + value_end=value_end, + flow_amount=Money("0.0000"), + is_provisional=is_provisional, + ) + if recorded_at is not None: + # Explicit, deterministic `recorded_at` rather than the server-side `now()` default -- + # avoids any dependency on client/server clock skew for a watermark-boundary test. + row.recorded_at = recorded_at + return row + + +def _performance( + customer_id: uuid.UUID, + *, + performance_range: str, + today: date = TODAY, + as_of: Watermark | None = None, +): + with ValuationUnitOfWork( + customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP + ) as uow: + return PortfolioPerformanceService(uow).performance( + customer_id, + performance_range=performance_range, # type: ignore[arg-type] + as_of=as_of or Watermark.live(), + today=today, + ) + + +def test_folds_a_single_sub_period_into_two_points(db_committing: Session) -> None: + customer_id = _seed_customer(db_committing) + db_committing.add( + _sub_period( + customer_id, + start=TODAY - timedelta(days=10), + end=TODAY, + return_pct=Decimal("0.0500000000"), + value_begin=Money("10000.0000"), + value_end=Money("10500.0000"), + ) + ) + db_committing.commit() + + result = _performance(customer_id, performance_range="1m") + + assert result.period_start == TODAY - timedelta(days=10) + assert result.period_end == TODAY + assert result.cumulative_twr == Decimal("0.0500000000") + assert result.is_provisional is False + assert [(p.as_of_date, p.value) for p in result.points] == [ + (TODAY - timedelta(days=10), Money("10000.0000")), + (TODAY, Money("10500.0000")), + ] + + +def test_range_excludes_sub_periods_outside_the_window(db_committing: Session) -> None: + customer_id = _seed_customer(db_committing) + db_committing.add_all( + [ + _sub_period( + customer_id, + start=TODAY - timedelta(days=200), + end=TODAY - timedelta(days=190), + return_pct=Decimal("0.0200000000"), + value_begin=Money("9000.0000"), + value_end=Money("9180.0000"), + ), + _sub_period( + customer_id, + start=TODAY - timedelta(days=10), + end=TODAY, + return_pct=Decimal("0.0500000000"), + value_begin=Money("10000.0000"), + value_end=Money("10500.0000"), + ), + ] + ) + db_committing.commit() + + one_month = _performance(customer_id, performance_range="1m") + all_range = _performance(customer_id, performance_range="all") + + assert len(one_month.points) == 2 + assert one_month.cumulative_twr == Decimal("0.0500000000") + assert len(all_range.points) == 3 + # (1.02 * 1.05) - 1, quantized to sub_period_return.return_pct's NUMERIC(18,10) scale. + assert all_range.cumulative_twr == Decimal("0.0710000000") + assert all_range.period_start == TODAY - timedelta(days=200) + + +def test_no_coverage_is_an_empty_series_not_an_error(db_committing: Session) -> None: + customer_id = _seed_customer(db_committing) + + result = _performance(customer_id, performance_range="1y") + + assert result.period_start is None + assert result.points == () + assert result.cumulative_twr == Decimal("0.0000000000") + + +def test_watermark_excludes_a_row_recorded_after_the_cutoff(db_committing: Session) -> None: + customer_id = _seed_customer(db_committing) + cutoff = datetime(2026, 9, 1, tzinfo=UTC) + db_committing.add( + _sub_period( + customer_id, + start=TODAY - timedelta(days=10), + end=TODAY, + return_pct=Decimal("0.0500000000"), + value_begin=Money("10000.0000"), + value_end=Money("10500.0000"), + recorded_at=cutoff + timedelta(days=1), + ) + ) + db_committing.commit() + + result = _performance( + customer_id, performance_range="all", as_of=Watermark.as_published(cutoff) + ) + + assert result.points == () + assert result.cumulative_twr == Decimal("0.0000000000") + + +def test_is_provisional_when_any_row_in_range_is_provisional(db_committing: Session) -> None: + customer_id = _seed_customer(db_committing) + db_committing.add( + _sub_period( + customer_id, + start=TODAY - timedelta(days=10), + end=TODAY, + return_pct=Decimal("0.0000000000"), + value_begin=Money("10000.0000"), + value_end=Money("10000.0000"), + is_provisional=True, + ) + ) + db_committing.commit() + + result = _performance(customer_id, performance_range="1m") + + assert result.is_provisional is True + + +def test_another_customers_rows_are_never_included(db_committing: Session) -> None: + customer_id = _seed_customer(db_committing) + other_customer_id = _seed_customer(db_committing) + db_committing.add( + _sub_period( + other_customer_id, + start=TODAY - timedelta(days=10), + end=TODAY, + return_pct=Decimal("0.9900000000"), + value_begin=Money("1.0000"), + value_end=Money("199.0000"), + ) + ) + db_committing.commit() + + result = _performance(customer_id, performance_range="all") + + assert result.points == () + assert result.cumulative_twr == Decimal("0.0000000000") diff --git a/docs/decisions/26-portfolio-performance-series.md b/docs/decisions/26-portfolio-performance-series.md new file mode 100644 index 0000000..eafba1f --- /dev/null +++ b/docs/decisions/26-portfolio-performance-series.md @@ -0,0 +1,98 @@ +# 26 — Portfolio performance series sourced from `sub_period_return` + +## Status + +Accepted + +## Context + +The Portfolio page redesign needs two pieces of new backend surface a customer has never had +direct access to before: what they actually hold (`GET /api/v1/portfolios/holdings`, distinct from +the model's *target* weights S8/S9 already expose) and a chartable performance history +(`GET /api/v1/portfolios/performance`). The holdings endpoint is a thin read model over +`DriftEvaluationService.evaluate()` (S9 §4) and needs no new architectural decision — it reuses an +existing, already-correct valuation path. + +The performance endpoint does need one. FR-16/17 and S4 §5 (ADR 3) already compute time-weighted +return, but only two shapes of it exist today: + +- `GET /api/v1/valuation/returns` — a single linked TWR figure for one caller-supplied + `[period_start, period_end]` window, no series. +- `GET /api/v1/statements` — the **as-published** monthly `(period_start, period_end, twr, + balance)` series (S6 §8, ADR 6). This is the authoritative "what did we tell the customer" + record, immutable once published, and cross-checked for tamper detection. + +Neither is what a Portfolio page chart needs: an as-of-now, live series of dated portfolio values +the customer can flip between `1m`/`3m`/`6m`/`1y`/`all`. `sub_period_return` (S4 §3.5) is the only +table in the system that stores a dated portfolio value at all (`value_begin`/`value_end` per +sub-period) — everything else is either a point-in-time computation over the ledger or the +as-published snapshot above. Its existing repository methods (`latest_for_sub_period`, +`as_of_for_sub_period`, `containing`) all resolve a single row for a known period; none returns an +ordered range. + +## Decision + +- **Source the live performance series from `sub_period_return`**, via a new + `SubPeriodReturnRepository.list_in_range()` read — every latest-`recorded_at` row whose window + overlaps the requested `[start_date, end_date]`, visible as of a `Watermark` (ADR 6's watermark + discipline applied to a live read: `Watermark.live()`, never `datetime.now()` inlined in the + service). `PortfolioPerformanceService` folds the ordered rows into `[{date, value}]` points + (`value_begin` of the first row, then `value_end` of every row) and reuses + `TwrService.link()` — the exact `product(1 + r_i) - 1` re-link `SnapshotService`'s restatement + path already uses — to fold the same rows into one cumulative TWR, rather than re-deriving + returns from the ledger a second time. +- **This is the live/as-of-now view.** `GET /api/v1/statements` remains the sole authoritative + *published* series (ADR 6); the two are never merged or presented as interchangeable. The + performance endpoint's figures can move on a later restatement (S6 §7) exactly the same way + `GET /valuation/returns` already can; `GET /api/v1/statements` is what stays queryable unchanged. +- **`range` is a strict allowlist (`1m`/`3m`/`6m`/`1y`/`all`), never a free-form date pair.** A + free-form `[start, end]` query would let a caller request an arbitrarily wide or oddly-anchored + window, which is both an unbounded-query risk (NFR-class concern the rest of this codebase treats + seriously — cf. every other list endpoint's pagination/limit discipline) and uncacheable, since + the response shape depends on wall-clock "now" for every distinct pair a client could construct. + A fixed enum keeps the response cacheable per-customer-per-range and keeps the query bounded. +- The service method takes `range` as a required keyword argument, no default (mirroring + `as_of: Watermark`'s no-default convention, ADR 6/14) — a caller cannot silently get "some" + range. The controller alone may default the query parameter (e.g. `1m`) before calling the + service, matching how every other date-boundary read in this codebase pushes the "what does + 'no input' mean" decision to the edge, not into the service. +- The series reflects whatever `sub_period_return` coverage already exists for that customer + (populated by `GET /valuation/returns`, statement publication, restatement, and fee accrual's + high-water-mark computation — S4 §5, S6, S10). The endpoint is a pure read: it never triggers a + fresh `TwrService.compute_twr()` write path itself. A customer with no covered sub-periods in the + requested range gets an empty `points` series and a zero cumulative TWR — the same "legitimately + nothing here yet" shape `ValuationService`/`DriftEvaluationService` already use for an + unfunded account, not an error. + +## Consequences + +- No new table, no Alembic migration — `sub_period_return` already carries everything the series + needs. +- The performance series' completeness is bounded by which periods some other flow has already + caused to be computed and stored; it is not guaranteed to be a dense, gap-free daily series. This + is an explicit trade-off (see alternatives below), not an oversight, and is safe precisely because + every consumer of "the published figure" already has its own, independently-derived source of + truth (`GET /api/v1/statements`) that this endpoint never substitutes for. +- `list_in_range()` follows the same latest-`recorded_at`-per-window dedupe `containing()` already + established, so a restated sub-period is picked up automatically without any change to this + endpoint. + +## Alternatives considered + +- **Have the performance endpoint call `TwrService.compute_twr()` itself for the requested range, + materializing any missing sub-periods on the fly.** Would guarantee a dense series, but turns a + plain "view your performance" `GET` into a request that reads the whole ledger and writes new + `sub_period_return` rows as a side effect of a read — a surprising, harder-to-reason-about + contract, and redundant with the read paths (`GET /valuation/returns`, statement publication, + fee accrual) that already populate this table as their own side effect. Rejected in favor of a + pure read; can be revisited if product data shows real gaps once this ships. +- **Derive the series live from the ledger** (`ValuationService.value_book()` per day in range), + bypassing `sub_period_return` entirely. Avoids the coverage gap above, but re-derives TWR-relevant + figures the codebase already has a canonical, restatement-composable way to produce + (`TwrService.link()`), and turns a chart endpoint into an O(days) ledger scan. Rejected — exactly + the kind of duplicated derivation ADR 3/S4 §5 exists to avoid. +- **Accept a free-form `[start, end]` query parameter pair instead of an enum.** More flexible for + a future custom-range chart control, but unbounded (a caller could request decades of history) + and uncacheable per the reasoning above. Rejected for v1; a future ADR can widen this if the + product need materializes, the same way `GET /valuation/returns` already accepts explicit dates + for its single-window use case. From a8a571036b261320389fac1191c4e6aa6f94678c Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 22:50:55 -0700 Subject: [PATCH 6/8] feat(orders): add cancellation and fill/lot depth (ADR 25) POST /api/v1/orders//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. --- backend/app/controllers/api/orders.py | 150 ++++- .../app/integrations/alpaca/broker_adapter.py | 5 + backend/app/integrations/fake/fake_broker.py | 8 + backend/app/integrations/ports.py | 6 + backend/app/services/orders/order_service.py | 52 ++ backend/app/views/orders.py | 38 +- backend/tests/api/test_orders.py | 529 +++++++++++++++++- backend/tests/contract/test_broker_port.py | 14 + .../integration/test_order_cancellation.py | 283 ++++++++++ docs/decisions/25-order-cancellation.md | 143 +++++ 10 files changed, 1209 insertions(+), 19 deletions(-) create mode 100644 backend/tests/integration/test_order_cancellation.py create mode 100644 docs/decisions/25-order-cancellation.md diff --git a/backend/app/controllers/api/orders.py b/backend/app/controllers/api/orders.py index 532f5e5..af794d0 100644 --- a/backend/app/controllers/api/orders.py +++ b/backend/app/controllers/api/orders.py @@ -1,5 +1,6 @@ -"""Order routes (S3 §6): create, approve, and read. Broker events reach the system over the -`trade_updates` websocket (ADR 22), not a webhook controller here. +"""Order routes (S3 §6): create, approve, cancel-request, and read. Broker events reach the +system over the `trade_updates` websocket (ADR 22), not a webhook controller here; `cancel_order` +below only *requests* a cancellation (ADR 25) -- it never sets `OrderStatus.CANCELED` itself. `POST /orders` accepts `reference_price` (order's schema has no price field). `symbol` is resolved from S5's securities catalogue, not the request body. `current_user.id` is never read directly @@ -10,7 +11,7 @@ import uuid from datetime import UTC, datetime -from typing import Any +from typing import TYPE_CHECKING, Any from flask import Blueprint, jsonify, request from flask import session as flask_session @@ -32,13 +33,13 @@ request_hash, resolve_replay, ) -from app.core.money import ( # noqa: TC001 -- Pydantic resolves field annotations at class-build time - Price, - Units, -) +from app.core.money import Price, Units +from app.core.security import audited from app.core.uow import SessionRole from app.extensions import limiter +from app.integrations.alpaca.broker_adapter import AlpacaBrokerAdapter 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.orders.approval_hold_service import ApprovalHoldService from app.services.orders.holds_provider import OrderHoldsProvider @@ -47,17 +48,23 @@ InsufficientInvestableCashError, InvalidOrderTransitionError, OrderCreationRequest, + OrderNotCancellableError, OrderNotFoundError, OrderService, ) from app.services.orders.uow import OrdersUnitOfWork from app.views.orders import ( + ConsumedLotResponse, + OpenedLotResponse, OrderDetailResponse, OrderEventResponse, OrderListResponse, OrderResponse, ) +if TYPE_CHECKING: + from app.integrations.ports import BrokerPort + orders_bp = Blueprint("orders", __name__, url_prefix="/api/v1/orders") _STAFF_ROLES = ("adviser", "admin") @@ -103,6 +110,56 @@ def _order_service(uow: OrdersUnitOfWork) -> OrderService: ) +def _build_broker_port() -> BrokerPort: + """The live `BrokerPort`, built here so tests can substitute a fake via monkeypatch (mirrors + `identity.py`'s `_build_kyc_port`).""" + settings = get_settings() + if not settings.has_alpaca_credentials: + raise RuntimeError("ALPACA_API_KEY_ID/ALPACA_API_SECRET_KEY is not configured") + return AlpacaBrokerAdapter( + api_key_id=settings.alpaca_api_key_id.get_secret_value(), # type: ignore[union-attr] + api_secret_key=settings.alpaca_api_secret_key.get_secret_value(), # type: ignore[union-attr] + ) + + +# Customer-safe allowlist for `CustomerNotEligibleError.reason` (S2's KYC/account-approval gate). +# `str(exc)`/the raw internal reason must never reach the wire -- only these disclosable codes, +# never a KYC-provider-specific denial detail (Stripe Identity's own reason strings included). +_DEFAULT_INELIGIBILITY_CODE = "customer_not_eligible" +_CUSTOMER_SAFE_INELIGIBILITY_CODES: dict[str, str] = { + "customer_not_found": _DEFAULT_INELIGIBILITY_CODE, + "kyc_status_pending": "kyc_verification_pending", + "kyc_status_rejected": "kyc_verification_rejected", + "account_approval_status_pending": "account_approval_pending", + "account_approval_status_rejected": "account_approval_rejected", +} + + +def _ineligibility_code(exc: CustomerNotEligibleError) -> str: + """Never falls through to the raw `exc.reason`: an unmapped reason -- e.g. a future gate this + allowlist hasn't been extended for yet -- degrades to the generic safe code, not a leak.""" + return _CUSTOMER_SAFE_INELIGIBILITY_CODES.get(exc.reason, _DEFAULT_INELIGIBILITY_CODE) + + +def _to_event_response(event: OrderEvent) -> OrderEventResponse: + """Only a `fill` event's payload carries `quantity`/`price` (`trade_update_handler.py`).""" + quantity: Units | None = None + price: Price | None = None + if event.event_type is OrderEventType.FILL: + raw_quantity = event.payload.get("quantity") + raw_price = event.payload.get("price") + quantity = Units(str(raw_quantity)) if raw_quantity is not None else None + price = Price(str(raw_price)) if raw_price is not None else None + return OrderEventResponse( + seq=event.seq, + event_type=event.event_type.value, + execution_id=event.execution_id, + quantity=quantity, + price=price, + recorded_at=event.recorded_at, + ) + + def _to_order_response(uow: OrdersUnitOfWork, order: Order) -> OrderResponse: """`symbol` is resolved from S5's securities catalogue; `order` has no `symbol` column.""" security = uow.securities.get_by_id(order.security_id) @@ -166,7 +223,9 @@ def create_order() -> Any: if order.status is OrderStatus.APPROVED: service.enqueue_submission(order) except CustomerNotEligibleError as exc: - raise ForbiddenError(str(exc)) from exc + raise ForbiddenError( + "Customer is not eligible to place orders", code=_ineligibility_code(exc) + ) from exc except InsufficientInvestableCashError as exc: raise ValidationError(str(exc), code="insufficient_investable_cash") from exc @@ -215,6 +274,56 @@ def approve_order(order_id: uuid.UUID) -> Any: return jsonify(body), 200 +@audited("orders.cancel_requested") +def _audited_request_cancel( + *, + uow: OrdersUnitOfWork, + customer_id: uuid.UUID, + order_id: uuid.UUID, + service: OrderService, + broker: BrokerPort, +) -> Order: + """Indirection so `@audited` sees `uow`/`customer_id` as its own arguments (mirrors + `admin/kyc_overrides.py`'s `_apply_override`); both are read by the decorator via call-frame + introspection, not used in this body.""" + return service.request_cancel(order_id, broker=broker) + + +@orders_bp.route("//cancel", methods=["POST"]) +@limiter.limit("30 per minute") +def cancel_order(order_id: uuid.UUID) -> Any: + """ADR 25: requests broker cancellation; never itself sets `canceled` -- see + `OrderService.request_cancel`. `200` here means "the broker was asked", not "canceled".""" + if not current_user.is_authenticated: + raise UnauthenticatedError("Authentication required") + if current_user.role != "customer": + raise ForbiddenError("Only the owning customer may cancel an order") + + customer_id = _resolve_customer_id() + + with OrdersUnitOfWork(customer_id=customer_id, role=SessionRole.CUSTOMER) as uow: + # Tenant-scoped get_for_update is the ownership check: another's order is invisible. + try: + service = _order_service(uow) + order = _audited_request_cancel( + uow=uow, + customer_id=customer_id, + order_id=order_id, + service=service, + broker=_build_broker_port(), + ) + except OrderNotFoundError as exc: + raise NotFoundError(str(exc)) from exc + except OrderNotCancellableError as exc: + raise ConflictError(str(exc)) from exc + + view = _to_order_response(uow, order) + body = view.model_dump(mode="json") + uow.commit() + + return jsonify(body), 200 + + @orders_bp.route("", methods=["GET"]) @limiter.limit("60 per minute") def list_orders() -> Any: @@ -248,10 +357,31 @@ def get_order(order_id: uuid.UUID) -> Any: order = uow.orders.get_by_id(order_id) if order is None or order.customer_id != customer_id: raise NotFoundError(f"no order found for id={order_id!r}") - events = uow.order_events.list_for_order(order_id) + events = uow.order_events.list_for_order(order_id) # already `seq`-ordered + + # Lot linkage (S5 FK): one bulk query keyed on this order's own fill execution IDs -- + # never N+1. A buy shows the lots it opened; a sell shows the lots it consumed. + fill_execution_ids = [e.execution_id for e in events if e.execution_id is not None] + opened_lots: list[OpenedLotResponse] = [] + consumed_lots: list[ConsumedLotResponse] = [] + if order.side is OrderSide.BUY: + opened_lots = [ + OpenedLotResponse.model_validate(lot) + for lot in uow.tax_lots.list_by_opening_execution_ids(fill_execution_ids) + ] + else: + consumed_lots = [ + ConsumedLotResponse.model_validate(consumption) + for consumption in uow.lot_consumptions.list_by_closing_execution_ids( + fill_execution_ids + ) + ] + view = OrderDetailResponse( order=_to_order_response(uow, order), - events=[OrderEventResponse.model_validate(e) for e in events], + events=[_to_event_response(e) for e in events], + opened_lots=opened_lots, + consumed_lots=consumed_lots, ) return jsonify(view.model_dump(mode="json")), 200 diff --git a/backend/app/integrations/alpaca/broker_adapter.py b/backend/app/integrations/alpaca/broker_adapter.py index 14e5aa7..fba7051 100644 --- a/backend/app/integrations/alpaca/broker_adapter.py +++ b/backend/app/integrations/alpaca/broker_adapter.py @@ -48,5 +48,10 @@ def submit_order( broker_order_id=str(result.id), client_order_id=result.client_order_id ) + def cancel_order(self, *, broker_order_id: str) -> None: + """ADR 25: requests broker-side cancellation; does not wait for/return a confirmation. + Raises `alpaca.common.exceptions.APIError` uncaught, matching `submit_order`'s posture.""" + self._client.cancel_order_by_id(broker_order_id) + __all__ = ["AlpacaBrokerAdapter"] diff --git a/backend/app/integrations/fake/fake_broker.py b/backend/app/integrations/fake/fake_broker.py index 2b6f94a..9543f1d 100644 --- a/backend/app/integrations/fake/fake_broker.py +++ b/backend/app/integrations/fake/fake_broker.py @@ -20,6 +20,8 @@ class BrokerSubmissionRejectedError(RuntimeError): class FakeBrokerAdapter: def __init__(self) -> None: self.submitted_orders: list[dict[str, Any]] = [] + self.cancel_requests: list[str] = [] + """`broker_order_id`s passed to `cancel_order`, in call order (ADR 25 test double).""" self._reject_client_order_ids: set[str] = set() def reject_on_submit(self, client_order_id: str) -> None: @@ -47,6 +49,12 @@ def submit_order( ) return BrokerOrderHandle(broker_order_id=broker_order_id, client_order_id=client_order_id) + def cancel_order(self, *, broker_order_id: str) -> None: + """Records the request; ADR 25 -- never itself produces a `canceled` trade-update. + Call `canceled_message(...)` and feed it through `AlpacaTradeUpdateHandler` to simulate + the broker's confirmation.""" + self.cancel_requests.append(broker_order_id) + @staticmethod def _base_message( *, event: str, broker_order_id: str, client_order_id: str, symbol: str diff --git a/backend/app/integrations/ports.py b/backend/app/integrations/ports.py index df97774..5802bd0 100644 --- a/backend/app/integrations/ports.py +++ b/backend/app/integrations/ports.py @@ -92,6 +92,12 @@ def submit_order( """`client_order_id` is generated by the caller (S3 §3.1/§5), never produced by this port.""" ... + def cancel_order(self, *, broker_order_id: str) -> None: + """Requests broker-side cancellation of an already-submitted order (ADR 25). Only + *requests* the cancel — the broker's own `canceled` trade-update (ADR 22), folded by + `OrderProjectionService`, is the only thing that ever changes `order.status`.""" + ... + # Fills arrive over Alpaca's `trade_updates` websocket (ADR 22), not a BrokerPort method — see # `integrations/alpaca/trade_updates_consumer.py`. diff --git a/backend/app/services/orders/order_service.py b/backend/app/services/orders/order_service.py index f70d065..3b7c296 100644 --- a/backend/app/services/orders/order_service.py +++ b/backend/app/services/orders/order_service.py @@ -33,6 +33,13 @@ OrderSide.SELL: PortOrderSide.SELL, } +_CANCELLABLE_STATUSES = frozenset( + {OrderStatus.SUBMITTED, OrderStatus.ACCEPTED, OrderStatus.PARTIALLY_FILLED} +) +"""ADR 25: only an order already at the broker can be cancel-requested. Pre-broker orders +(`draft`/`awaiting_approval`/`approved`) have no broker order to cancel; every terminal status +(`filled`/`rejected`/`canceled`/`expired`) is, by definition, no longer cancellable.""" + class CustomerNotEligibleError(RuntimeError): """KYC/account-approval gate not satisfied (S3 §7 case 2); `reason` names the specific gate.""" @@ -66,6 +73,20 @@ def __init__(self, order_id: uuid.UUID, *, from_status: OrderStatus, action: str ) +class OrderNotCancellableError(RuntimeError): + """ADR 25: raised for any order outside `_CANCELLABLE_STATUSES` -- pre-broker, already + terminal, or already filled.""" + + def __init__(self, order_id: uuid.UUID, *, status: OrderStatus) -> None: + super().__init__(f"order {order_id} cannot be canceled from status {status.value!r}") + self.status = status + + +class OrderHasNoBrokerOrderIdError(RuntimeError): + """Defensive only (ADR 25): every cancellable order has a `submitted` event carrying + `broker_order_id`, by construction of `submit_to_broker`.""" + + @dataclass(frozen=True, slots=True) class OrderCreationRequest: customer_id: uuid.UUID @@ -136,6 +157,23 @@ def approve(self, order_id: uuid.UUID) -> Order: order.status = OrderStatus.APPROVED return order + def request_cancel(self, order_id: uuid.UUID, *, broker: BrokerPort) -> Order: + """ADR 25: requests broker cancellation of an in-flight order. Never sets + `OrderStatus.CANCELED` and never touches the approval hold -- only the broker-confirmed + `canceled` event on the trade-updates websocket (ADR 22), folded by + `OrderProjectionService`, does either. Idempotent: a repeat call while the order is still + cancellable simply re-issues the broker cancel request (a no-op at the broker), and once + the order leaves `_CANCELLABLE_STATUSES` -- confirmed canceled, or raced into filled -- + further calls raise `OrderNotCancellableError`.""" + order = self._uow.orders.get_for_update(order_id) + if order is None: + raise OrderNotFoundError(f"no order found for id={order_id!r}") + if order.status not in _CANCELLABLE_STATUSES: + raise OrderNotCancellableError(order_id, status=order.status) + + broker.cancel_order(broker_order_id=self._broker_order_id(order)) + return order + def enqueue_submission(self, order: Order) -> None: """Enqueues the broker-submission outbox task; resolves `symbol` from S5's securities catalogue.""" security = self._uow.securities.get_by_id(order.security_id) @@ -176,6 +214,18 @@ def submit_to_broker(self, order_id: uuid.UUID, *, symbol: str, broker: BrokerPo # --- internals -------------------------------------------------------------------------- + def _broker_order_id(self, order: Order) -> str: + """The `broker_order_id` this order's `submitted` event recorded (`submit_to_broker`'s + `_synthesize_event` payload) -- never a new column, never re-derived.""" + for event in self._uow.order_events.list_for_order(order.id): + if event.event_type is OrderEventType.SUBMITTED: + broker_order_id = event.payload.get("broker_order_id") + if isinstance(broker_order_id, str) and broker_order_id: + return broker_order_id + raise OrderHasNoBrokerOrderIdError( # pragma: no cover - defensive, see class docstring + f"order {order.id} has no submitted broker_order_id" + ) + def _synthesize_event( self, order: Order, event_type: OrderEventType, *, payload: dict[str, Any] ) -> None: @@ -210,6 +260,8 @@ def _require_eligible_customer(self, customer_id: uuid.UUID) -> None: "CustomerNotEligibleError", "InvalidOrderTransitionError", "OrderCreationRequest", + "OrderHasNoBrokerOrderIdError", + "OrderNotCancellableError", "OrderNotFoundError", "OrderService", "SecurityNotFoundError", diff --git a/backend/app/views/orders.py b/backend/app/views/orders.py index e192a71..b0448c4 100644 --- a/backend/app/views/orders.py +++ b/backend/app/views/orders.py @@ -7,11 +7,12 @@ from __future__ import annotations import uuid # noqa: TC003 -- Pydantic resolves field annotations at class-build time. -from datetime import datetime # noqa: TC003 +from datetime import date, datetime # noqa: TC003 from pydantic import BaseModel, ConfigDict from app.core.money import ( # noqa: TC001 -- Pydantic resolves field annotations at class-build time. + Money, Price, Units, ) @@ -40,12 +41,45 @@ class OrderEventResponse(BaseModel): seq: int event_type: str execution_id: str | None + quantity: Units | None + """Per-fill quantity; only a `fill` event carries one (e.g. `submitted` never does).""" + price: Price | None + """Per-fill price; only a `fill` event carries one.""" recorded_at: datetime +class OpenedLotResponse(BaseModel): + """A tax lot opened by one of this (buy) order's own fills.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + quantity_opened: Units + quantity_remaining: Units + original_cost_basis: Money + acquired_at: date + opening_fill_execution_id: str + + +class ConsumedLotResponse(BaseModel): + """A lot consumption drawn by one of this (sell) order's own fills.""" + + model_config = ConfigDict(from_attributes=True) + + tax_lot_id: uuid.UUID + quantity_consumed: Units + realized_gain_loss: Money + sale_date: date + closing_fill_execution_id: str + + class OrderDetailResponse(BaseModel): order: OrderResponse events: list[OrderEventResponse] + opened_lots: list[OpenedLotResponse] + """Non-empty only for a buy order: the lots its own fills opened.""" + consumed_lots: list[ConsumedLotResponse] + """Non-empty only for a sell order: the lot consumptions its own fills drew from.""" class OrderListResponse(BaseModel): @@ -53,6 +87,8 @@ class OrderListResponse(BaseModel): __all__ = [ + "ConsumedLotResponse", + "OpenedLotResponse", "OrderDetailResponse", "OrderEventResponse", "OrderListResponse", diff --git a/backend/tests/api/test_orders.py b/backend/tests/api/test_orders.py index 09ecfe6..018d559 100644 --- a/backend/tests/api/test_orders.py +++ b/backend/tests/api/test_orders.py @@ -6,7 +6,7 @@ import uuid from collections.abc import Iterator -from datetime import date +from datetime import UTC, date, datetime from typing import Any import pytest @@ -15,21 +15,32 @@ from sqlalchemy.orm import Session from werkzeug.test import TestResponse -from app.core.money import Money +from app.controllers.api import orders as orders_controller +from app.core.money import Money, Price, Units +from app.integrations.fake.fake_broker import FakeBrokerAdapter from app.models.identity.customer import AccountApprovalStatus, Customer, KycStatus from app.models.ledger.account import Account, AccountRole from app.models.ledger.customer_cash_lock import CustomerCashLock from app.models.ledger.journal_entry import JournalEntry, JournalEntryType +from app.models.ledger.lot_consumption import LotConsumption from app.models.ledger.posting import Posting from app.models.ledger.settlement_obligation import SettlementObligation +from app.models.ledger.tax_lot import LotDesignation, TaxLot +from app.models.ledger.wash_sale_adjustment import WashSaleAdjustment from app.models.marketdata.security import Security, SecurityAssetClass +from app.models.ops.admin_audit_log import AdminAuditLog from app.models.ops.idempotency_key import IdempotencyKey from app.models.ops.inbound_event import InboundEvent, InboundEventSource from app.models.ops.job_outbox import JobOutbox -from app.models.orders.approval_hold import ApprovalHold -from app.models.orders.order import Order -from app.models.orders.order_event import OrderEvent +from app.models.orders.approval_hold import ( + ApprovalHold, + ApprovalHoldReleaseReason, + ApprovalHoldStatus, +) +from app.models.orders.order import Order, OrderStatus +from app.models.orders.order_event import OrderEvent, OrderEventType from app.services.ledger.posting_service import PostingLeg, PostingService +from app.services.orders.order_service import CustomerNotEligibleError CUSTOMER_EMAIL = "order-customer@trueup.example" CUSTOMER_PASSWORD = "correct-horse-battery" @@ -52,8 +63,12 @@ def _order_tables(owner_engine: Engine) -> Iterator[None]: Order.__table__, OrderEvent.__table__, ApprovalHold.__table__, + TaxLot.__table__, + LotConsumption.__table__, + WashSaleAdjustment.__table__, IdempotencyKey.__table__, JobOutbox.__table__, + AdminAuditLog.__table__, ] for table in tables: table.create(bind=owner_engine, checkfirst=True) @@ -80,9 +95,10 @@ def _clean_order_tables(owner_engine: Engine, _order_tables: None) -> Iterator[N with owner_engine.begin() as connection: connection.execute( text( - "TRUNCATE approval_hold, order_event, \"order\", customer_cash_lock, " - "idempotency_key, job_outbox, settlement_obligation, posting, journal_entry, " - "account, inbound_event RESTART IDENTITY CASCADE" + "TRUNCATE approval_hold, wash_sale_adjustment, lot_consumption, tax_lot, " + "order_event, \"order\", customer_cash_lock, " + "idempotency_key, job_outbox, admin_audit_log, settlement_obligation, posting, " + "journal_entry, account, inbound_event RESTART IDENTITY CASCADE" ) ) @@ -185,6 +201,68 @@ def _create_payload(**overrides: Any) -> dict[str, Any]: return payload +def _advance_order_to_submitted( + owner_engine: Engine, order_id: uuid.UUID, *, broker_order_id: str = "fake-broker-order-1" +) -> None: + """Directly synthesizes the `submitted` event/projection/hold-release + `OrderService.submit_to_broker` would produce -- API tests never run the outbox worker that + actually calls it.""" + session = Session(bind=owner_engine, expire_on_commit=False) + try: + order = session.get(Order, order_id) + assert order is not None + session.add( + OrderEvent( + order_id=order_id, + seq=1, + event_type=OrderEventType.SUBMITTED, + payload={"broker_order_id": broker_order_id}, + ) + ) + order.status = OrderStatus.SUBMITTED + hold = session.query(ApprovalHold).filter_by(order_id=order_id).one_or_none() + if hold is not None and hold.status is ApprovalHoldStatus.ACTIVE: + hold.status = ApprovalHoldStatus.RELEASED + hold.released_at = datetime.now(UTC) + hold.release_reason = ApprovalHoldReleaseReason.APPROVED_AND_SUBMITTED + session.commit() + finally: + session.close() + + +def _record_fill( + owner_engine: Engine, + order_id: uuid.UUID, + *, + seq: int, + execution_id: str, + quantity: str, + price: str, + status: OrderStatus = OrderStatus.FILLED, +) -> None: + """Directly inserts a `fill` `order_event` -- fills arrive over the trade-updates websocket + (ADR 22), never through this HTTP API.""" + session = Session(bind=owner_engine, expire_on_commit=False) + try: + order = session.get(Order, order_id) + assert order is not None + session.add( + OrderEvent( + order_id=order_id, + seq=seq, + event_type=OrderEventType.FILL, + execution_id=execution_id, + payload={"quantity": quantity, "price": price}, + ) + ) + order.status = status + order.filled_quantity = Units(quantity) + order.average_fill_price = Price(price) + session.commit() + finally: + session.close() + + # --- create: happy path -------------------------------------------------------------------- @@ -462,3 +540,438 @@ def test_get_order_with_no_session_is_rejected(api_client: FlaskClient) -> None: response = api_client.get(f"/api/v1/orders/{uuid.uuid4()}") assert response.status_code == 401 assert response.get_json()["code"] == "unauthenticated" + + +# --- read: fill timeline (S3 §6 order-detail depth) ------------------------------------------ + + +def test_get_order_includes_a_fill_timeline_in_seq_order( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + create_response = client.post( + "/api/v1/orders", + json=_create_payload(), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + order_id = uuid.UUID(create_response.get_json()["id"]) + _advance_order_to_submitted(owner_engine, order_id) + _record_fill( + owner_engine, + order_id, + seq=2, + execution_id="exec-timeline-1", + quantity="10", + price="101.50", + ) + + response = client.get(f"/api/v1/orders/{order_id}") + + assert response.status_code == 200 + events = response.get_json()["events"] + seqs = [e["seq"] for e in events] + assert seqs == sorted(seqs) # a real fill timeline, in `seq` order + + submitted = next(e for e in events if e["event_type"] == "submitted") + fill = next(e for e in events if e["event_type"] == "fill") + assert submitted["quantity"] is None # a `submitted` event carries neither + assert submitted["price"] is None + assert Units(fill["quantity"]) == Units("10") + assert Price(fill["price"]) == Price("101.50") + assert fill["execution_id"] == "exec-timeline-1" + + +# --- read: lot linkage (S5 FK; S3 §6 order-detail depth) ------------------------------------- + + +def test_get_order_shows_opened_lots_for_a_buy_order( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + create_response = client.post( + "/api/v1/orders", + json=_create_payload(), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + body = create_response.get_json() + order_id = uuid.UUID(body["id"]) + customer_id = uuid.UUID(body["customer_id"]) + _advance_order_to_submitted(owner_engine, order_id) + _record_fill( + owner_engine, order_id, seq=2, execution_id="exec-open-1", quantity="10", price="100.00" + ) + + lot_id = uuid.uuid4() + 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="exec-open-1", + quantity_opened=Units("10"), + quantity_remaining=Units("10"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.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() + + response = client.get(f"/api/v1/orders/{order_id}") + + assert response.status_code == 200 + detail = response.get_json() + assert detail["consumed_lots"] == [] + assert len(detail["opened_lots"]) == 1 + opened = detail["opened_lots"][0] + assert opened["id"] == str(lot_id) + assert opened["opening_fill_execution_id"] == "exec-open-1" + assert Units(opened["quantity_opened"]) == Units("10") + assert Money(opened["original_cost_basis"]) == Money("1000.00") + + +def test_get_order_shows_consumed_lots_for_a_sell_order( + api_client: FlaskClient, owner_engine: Engine +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + + # Seed the lot a subsequent sell will consume, via a separate buy order's own fill. + buy_response = client.post( + "/api/v1/orders", + json=_create_payload(), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + buy_body = buy_response.get_json() + buy_order_id = uuid.UUID(buy_body["id"]) + customer_id = uuid.UUID(buy_body["customer_id"]) + _advance_order_to_submitted(owner_engine, buy_order_id) + _record_fill( + owner_engine, + buy_order_id, + seq=2, + execution_id="exec-source-1", + quantity="10", + price="100.00", + ) + + lot_id = uuid.uuid4() + 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="exec-source-1", + quantity_opened=Units("10"), + quantity_remaining=Units("0"), + original_cost_basis=Money("1000.00"), + adjusted_basis=Money("1000.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() + + sell_response = client.post( + "/api/v1/orders", + json=_create_payload(side="sell"), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + sell_order_id = uuid.UUID(sell_response.get_json()["id"]) + _advance_order_to_submitted(owner_engine, sell_order_id) + _record_fill( + owner_engine, + sell_order_id, + seq=2, + execution_id="exec-close-1", + quantity="10", + price="105.00", + ) + + session = Session(bind=owner_engine, expire_on_commit=False) + try: + session.add( + LotConsumption( + closing_fill_execution_id="exec-close-1", + tax_lot_id=lot_id, + quantity_consumed=Units("10"), + realized_gain_loss=Money("50.00"), + is_provisional=False, + sale_date=date(2026, 1, 10), + ) + ) + session.commit() + finally: + session.close() + + response = client.get(f"/api/v1/orders/{sell_order_id}") + + assert response.status_code == 200 + detail = response.get_json() + assert detail["opened_lots"] == [] + assert len(detail["consumed_lots"]) == 1 + consumed = detail["consumed_lots"][0] + assert consumed["tax_lot_id"] == str(lot_id) + assert consumed["closing_fill_execution_id"] == "exec-close-1" + assert Units(consumed["quantity_consumed"]) == Units("10") + assert Money(consumed["realized_gain_loss"]) == Money("50.00") + + +# --- create: customer-safe ineligibility reasons (never the raw internal reason) ------------- + + +@pytest.mark.parametrize( + ("kyc_status", "account_status", "expected_code"), + [ + (KycStatus.pending, AccountApprovalStatus.approved, "kyc_verification_pending"), + (KycStatus.rejected, AccountApprovalStatus.approved, "kyc_verification_rejected"), + (KycStatus.approved, AccountApprovalStatus.pending, "account_approval_pending"), + (KycStatus.approved, AccountApprovalStatus.rejected, "account_approval_rejected"), + ], +) +def test_create_order_maps_each_ineligibility_reason_to_a_distinct_safe_code( + api_client: FlaskClient, + owner_engine: Engine, + kyc_status: KycStatus, + account_status: AccountApprovalStatus, + expected_code: str, +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + session = Session(bind=owner_engine, expire_on_commit=False) + try: + customer = session.query(Customer).filter_by(email=CUSTOMER_EMAIL).one() + customer.kyc_status = kyc_status + customer.account_approval_status = account_status + session.commit() + finally: + session.close() + + response = client.post( + "/api/v1/orders", + json=_create_payload(), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + + assert response.status_code == 403 + assert response.get_json()["code"] == expected_code + + +def test_ineligibility_code_maps_unmapped_reasons_to_the_generic_safe_code() -> None: + """`customer_not_found` is a defensive-only path -- an authenticated session already implies + flask-login's own loader resolved a live `Customer` row, so a real HTTP request can never + reach `_require_eligible_customer` with that reason. Exercised directly at the mapping- + function level rather than through the Flask test client for exactly that reason; also covers + any future reason this allowlist hasn't been extended for yet, which must degrade the same way + rather than leak the raw internal string.""" + assert ( + orders_controller._ineligibility_code(CustomerNotEligibleError("customer_not_found")) + == "customer_not_eligible" + ) + assert ( + orders_controller._ineligibility_code(CustomerNotEligibleError("some_future_gate")) + == "customer_not_eligible" + ) + + +# --- cancel (ADR 25): request only, never a local state mutation ----------------------------- + + +def _create_and_submit_order( + client: FlaskClient, + owner_engine: Engine, + csrf_token: str, + *, + broker_order_id: str = "fake-broker-order-1", +) -> uuid.UUID: + create_response = client.post( + "/api/v1/orders", + json=_create_payload(), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + order_id = uuid.UUID(create_response.get_json()["id"]) + _advance_order_to_submitted(owner_engine, order_id, broker_order_id=broker_order_id) + return order_id + + +@pytest.fixture +def _fake_broker(monkeypatch: pytest.MonkeyPatch) -> FakeBrokerAdapter: + broker = FakeBrokerAdapter() + monkeypatch.setattr(orders_controller, "_build_broker_port", lambda: broker) + return broker + + +def test_cancel_requests_the_broker_and_leaves_status_unchanged( + api_client: FlaskClient, owner_engine: Engine, _fake_broker: FakeBrokerAdapter +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + order_id = _create_and_submit_order( + client, owner_engine, csrf_token, broker_order_id="broker-order-A" + ) + + response = client.post( + f"/api/v1/orders/{order_id}/cancel", headers={"X-CSRFToken": csrf_token} + ) + + assert response.status_code == 200 + body = response.get_json() + assert body["status"] == "submitted" # unchanged -- only the websocket confirmation moves it + assert _fake_broker.cancel_requests == ["broker-order-A"] + + +def test_cancel_is_idempotent_while_the_order_is_still_cancellable( + api_client: FlaskClient, owner_engine: Engine, _fake_broker: FakeBrokerAdapter +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + order_id = _create_and_submit_order( + client, owner_engine, csrf_token, broker_order_id="broker-order-B" + ) + + first = client.post(f"/api/v1/orders/{order_id}/cancel", headers={"X-CSRFToken": csrf_token}) + second = client.post( + f"/api/v1/orders/{order_id}/cancel", headers={"X-CSRFToken": csrf_token} + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert _fake_broker.cancel_requests == ["broker-order-B", "broker-order-B"] + + +def test_cancel_a_pre_submission_order_is_a_conflict( + api_client: FlaskClient, owner_engine: Engine, _fake_broker: FakeBrokerAdapter +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + create_response = client.post( + "/api/v1/orders", + json=_create_payload(), + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + order_id = create_response.get_json()["id"] # still `approved` -- never submitted + + response = client.post( + f"/api/v1/orders/{order_id}/cancel", headers={"X-CSRFToken": csrf_token} + ) + + assert response.status_code == 409 + assert _fake_broker.cancel_requests == [] + + +def test_cancel_an_already_filled_order_is_a_conflict( + api_client: FlaskClient, owner_engine: Engine, _fake_broker: FakeBrokerAdapter +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + order_id = _create_and_submit_order(client, owner_engine, csrf_token) + _record_fill( + owner_engine, order_id, seq=2, execution_id="exec-filled-1", quantity="10", price="100.00" + ) + + response = client.post( + f"/api/v1/orders/{order_id}/cancel", headers={"X-CSRFToken": csrf_token} + ) + + assert response.status_code == 409 + assert _fake_broker.cancel_requests == [] + + +def test_cancel_another_customers_order_is_not_found( + api_client: FlaskClient, owner_engine: Engine, _fake_broker: FakeBrokerAdapter +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + order_id = _create_and_submit_order(client, owner_engine, csrf_token) + client.post("/api/v1/auth/logout", headers={"X-CSRFToken": csrf_token}) + + other_client, other_csrf = _authed_client( + api_client, owner_engine, email="other-canceler@trueup.example" + ) + + response = other_client.post( + f"/api/v1/orders/{order_id}/cancel", headers={"X-CSRFToken": other_csrf} + ) + + assert response.status_code == 404 + assert _fake_broker.cancel_requests == [] + + +def test_cancel_a_nonexistent_order_returns_not_found( + api_client: FlaskClient, owner_engine: Engine, _fake_broker: FakeBrokerAdapter +) -> None: + client, csrf_token = _authed_client(api_client, owner_engine) + + response = client.post( + f"/api/v1/orders/{uuid.uuid4()}/cancel", headers={"X-CSRFToken": csrf_token} + ) + + assert response.status_code == 404 + + +def test_cancel_with_no_session_is_rejected(api_client: FlaskClient) -> None: + response = api_client.post(f"/api/v1/orders/{uuid.uuid4()}/cancel") + # 400, not 401: CSRFProtect rejects first with no token at all, matching create/approve's own + # precedent (`test_create_order_with_no_session_is_rejected`) for every POST route here. + assert response.status_code == 400 + + +def test_cancel_releases_no_hold_before_the_websocket_confirms_it( + api_client: FlaskClient, owner_engine: Engine, _fake_broker: FakeBrokerAdapter +) -> None: + """The hold is already `released` (`approved_and_submitted`) by the time an order is + cancellable; `request_cancel` must not touch it, and the broker-confirmed `canceled` event + landing afterward must not touch it either (S3 §4/ADR 25).""" + client, csrf_token = _authed_client(api_client, owner_engine) + create_response = client.post( + "/api/v1/orders", + json=_create_payload(quantity="1000", reference_price="100.00"), # above threshold + headers={"X-CSRFToken": csrf_token, "Idempotency-Key": str(uuid.uuid4())}, + ) + order_id = uuid.UUID(create_response.get_json()["id"]) + client.post( + f"/api/v1/orders/{order_id}/approve", headers={"X-CSRFToken": csrf_token} + ) + _advance_order_to_submitted(owner_engine, order_id) + + def _hold_snapshot() -> tuple[str, str | None]: + session = Session(bind=owner_engine, expire_on_commit=False) + try: + hold = session.query(ApprovalHold).filter_by(order_id=order_id).one() + return hold.status.value, ( + hold.release_reason.value if hold.release_reason is not None else None + ) + finally: + session.close() + + before = _hold_snapshot() + assert before == ("released", "approved_and_submitted") + + response = client.post( + f"/api/v1/orders/{order_id}/cancel", headers={"X-CSRFToken": csrf_token} + ) + assert response.status_code == 200 + assert _hold_snapshot() == before # request_cancel touched neither status nor reason + + session = Session(bind=owner_engine, expire_on_commit=False) + try: + order = session.get(Order, order_id) + assert order is not None + session.add( + OrderEvent( + order_id=order_id, + seq=3, + event_type=OrderEventType.CANCELED, + payload={}, + ) + ) + order.status = OrderStatus.CANCELED + session.commit() + finally: + session.close() + + assert _hold_snapshot() == before # still the original release, not re-touched by "canceled" diff --git a/backend/tests/contract/test_broker_port.py b/backend/tests/contract/test_broker_port.py index b9d7555..fba1fc7 100644 --- a/backend/tests/contract/test_broker_port.py +++ b/backend/tests/contract/test_broker_port.py @@ -29,6 +29,20 @@ def test_fake_broker_adapter_satisfies_the_contract() -> None: _assert_valid_handle(FakeBrokerAdapter()) +def test_fake_broker_adapter_cancel_order_records_the_request() -> None: + """ADR 25: `cancel_order` only *requests* cancellation -- no live-Alpaca contract assertion is + made here, since a market order in paper trading can fill before a cancel would land, making a + real-adapter cancel assertion inherently flaky rather than a genuine contract check.""" + port = FakeBrokerAdapter() + handle = port.submit_order( + client_order_id=f"trueup-{uuid.uuid4()}", symbol="AAPL", side=OrderSide.BUY, quantity="1" + ) + + port.cancel_order(broker_order_id=handle.broker_order_id) + + assert port.cancel_requests == [handle.broker_order_id] + + @pytest.mark.requires_credentials @pytest.mark.skipif( not (os.environ.get("ALPACA_API_KEY_ID") and os.environ.get("ALPACA_API_SECRET_KEY")), diff --git a/backend/tests/integration/test_order_cancellation.py b/backend/tests/integration/test_order_cancellation.py new file mode 100644 index 0000000..886eba8 --- /dev/null +++ b/backend/tests/integration/test_order_cancellation.py @@ -0,0 +1,283 @@ +"""`OrderService.request_cancel` (ADR 25) against real PostgreSQL: the broker request itself, its +idempotency, its rejection paths, and -- the critical regression guard for the whole design -- a +`fill` trade-update racing and winning against a pending cancel request. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy import text + +from app.core.clock import InMemoryTradingCalendar, MarketClock +from app.core.db import DbRole +from app.core.money import Money, Units +from app.core.uow import SessionRole +from app.integrations.fake.fake_broker import FakeBrokerAdapter +from app.models.ledger.account import Account, AccountRole +from app.models.ledger.lot_consumption import LotConsumption +from app.models.ledger.tax_lot import TaxLot +from app.models.ledger.wash_sale_adjustment import WashSaleAdjustment +from app.models.marketdata.security import Security, SecurityAssetClass +from app.models.orders.approval_hold import ( + ApprovalHold, + ApprovalHoldReleaseReason, + ApprovalHoldStatus, +) +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.orders.approval_hold_service import ApprovalHoldService +from app.services.orders.holds_provider import OrderHoldsProvider +from app.services.orders.order_projection_service import OrderProjectionService +from app.services.orders.order_service import ( + OrderNotCancellableError, + OrderNotFoundError, + OrderService, +) +from app.services.orders.trade_update_handler import AlpacaTradeUpdateHandler +from app.services.orders.uow import OrdersUnitOfWork +from tests.integration.conftest import LEDGER_TABLES, insert_customer + +ORDER_TABLES = [ + *LEDGER_TABLES, + Security.__table__, + Order.__table__, + OrderEvent.__table__, + ApprovalHold.__table__, + TaxLot.__table__, + LotConsumption.__table__, + WashSaleAdjustment.__table__, +] + +NOW = datetime(2026, 9, 6, 14, 0, tzinfo=UTC) +BROKER_ORDER_ID = "broker-order-cancel-1" + + +@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 _owner_uow() -> OrdersUnitOfWork: + return OrdersUnitOfWork(customer_id=None, role=SessionRole.ADMIN, db_role=DbRole.OWNER) + + +def _market_clock_factory(_uow: OrdersUnitOfWork) -> MarketClock: + return MarketClock(InMemoryTradingCalendar()) + + +def _handler() -> AlpacaTradeUpdateHandler: + return AlpacaTradeUpdateHandler( + uow_factory=_owner_uow, now=lambda: NOW, market_clock_factory=_market_clock_factory + ) + + +def _seed_order( + *, status: OrderStatus, quantity: str = "10", with_submitted_event: bool = False +) -> tuple[uuid.UUID, str, uuid.UUID]: + """Seeds an order (and its hold) at `status`; `with_submitted_event` additionally records the + `submitted` `order_event` carrying `BROKER_ORDER_ID`, mirroring `submit_to_broker`.""" + with _owner_uow() as uow: + customer_id = insert_customer(uow.session) + security = Security( + symbol=f"TST{uuid.uuid4().hex[:6]}", + name="Test Co", + asset_class=SecurityAssetClass.EQUITY, + ) + uow.session.add(security) + uow.session.add(Account.create(AccountRole.CASH, customer_id=customer_id)) + uow.session.add(Account.create(AccountRole.CUSTOMER_EQUITY, customer_id=customer_id)) + uow.session.flush() + + order_id = uuid.uuid4() + order = Order( + id=order_id, + customer_id=customer_id, + security_id=security.id, + side=OrderSide.BUY, + quantity_requested=Units(quantity), + status=status, + filled_quantity=Units("0"), + client_order_id=derive_client_order_id(order_id), + ) + uow.orders.add(order) + uow.session.flush() + ApprovalHoldService(uow).open( + order_id=order_id, customer_id=customer_id, amount_money=Money("1000.00") + ) + + if with_submitted_event: + hold_service = ApprovalHoldService(uow) + projection = OrderProjectionService(uow, hold_service=hold_service, now=lambda: NOW) + projection.apply_new_event( + order, + OrderEvent( + order_id=order_id, + seq=1, + event_type=OrderEventType.SUBMITTED, + payload={"broker_order_id": BROKER_ORDER_ID}, + recorded_at=NOW, + ), + ) + uow.commit() + return order_id, order.client_order_id, security.id + + +def _service(uow: OrdersUnitOfWork) -> OrderService: + # request_cancel needs neither hold_service nor cash_policy; approve/create do, so both are + # still required constructor args -- pass through the real ones for shape consistency. + return OrderService( + uow, + hold_service=ApprovalHoldService(uow), + cash_policy=CashPolicyService(uow, holds_provider=OrderHoldsProvider(uow)), + approval_threshold_usd=Money("10000.00"), + now=lambda: NOW, + ) + + +def test_request_cancel_calls_the_broker_and_does_not_change_status() -> None: + order_id, _client_order_id, _security_id = _seed_order( + status=OrderStatus.SUBMITTED, with_submitted_event=True + ) + broker = FakeBrokerAdapter() + + with _owner_uow() as uow: + order = _service(uow).request_cancel(order_id, broker=broker) + assert order.status is OrderStatus.SUBMITTED # unchanged -- only the websocket moves it + uow.commit() + + assert broker.cancel_requests == [BROKER_ORDER_ID] + + with _owner_uow() as uow: + order = uow.orders.get_by_id(order_id) + assert order is not None and order.status is OrderStatus.SUBMITTED + + +def test_request_cancel_is_idempotent_while_still_cancellable() -> None: + order_id, _client_order_id, _security_id = _seed_order( + status=OrderStatus.SUBMITTED, with_submitted_event=True + ) + broker = FakeBrokerAdapter() + + with _owner_uow() as uow: + _service(uow).request_cancel(order_id, broker=broker) + uow.commit() + with _owner_uow() as uow: + _service(uow).request_cancel(order_id, broker=broker) + uow.commit() + + assert broker.cancel_requests == [BROKER_ORDER_ID, BROKER_ORDER_ID] + + +@pytest.mark.parametrize( + "status", + [ + OrderStatus.DRAFT, + OrderStatus.AWAITING_APPROVAL, + OrderStatus.APPROVED, + OrderStatus.FILLED, + OrderStatus.REJECTED, + OrderStatus.CANCELED, + OrderStatus.EXPIRED, + ], +) +def test_request_cancel_rejects_every_non_cancellable_status(status: OrderStatus) -> None: + order_id, _client_order_id, _security_id = _seed_order(status=status) + broker = FakeBrokerAdapter() + + with _owner_uow() as uow, pytest.raises(OrderNotCancellableError): + _service(uow).request_cancel(order_id, broker=broker) + + assert broker.cancel_requests == [] + + +def test_request_cancel_rejects_an_unknown_order() -> None: + broker = FakeBrokerAdapter() + with _owner_uow() as uow, pytest.raises(OrderNotFoundError): + _service(uow).request_cancel(uuid.uuid4(), broker=broker) + + +def test_fill_racing_a_pending_cancel_still_lands_on_filled_not_canceled() -> None: + """The critical regression guard for ADR 25: `request_cancel` never writes `order.status`, so + a `fill` that arrives after the cancel was requested still wins.""" + order_id, client_order_id, _security_id = _seed_order( + status=OrderStatus.SUBMITTED, with_submitted_event=True + ) + broker = FakeBrokerAdapter() + + with _owner_uow() as uow: + _service(uow).request_cancel(order_id, broker=broker) + uow.commit() + assert broker.cancel_requests == [BROKER_ORDER_ID] + + _handler().handle( + { + "event": "fill", + "execution_id": "exec-races-cancel-1", + "order": {"id": BROKER_ORDER_ID, "client_order_id": client_order_id}, + "timestamp": NOW.isoformat(), + "qty": "10", + "price": "50", + } + ) + + with _owner_uow() as uow: + order = uow.orders.get_by_id(order_id) + assert order is not None + assert order.status is OrderStatus.FILLED # not canceled + assert order.filled_quantity == Units("10") + + +def test_hold_is_released_only_by_the_broker_confirmed_cancel_never_by_the_request() -> None: + order_id, client_order_id, _security_id = _seed_order( + status=OrderStatus.SUBMITTED, with_submitted_event=True + ) + broker = FakeBrokerAdapter() + + with _owner_uow() as uow: + hold = uow.approval_holds.get_by_order_id(order_id) + assert hold is not None + # `submitted` already released it -- request_cancel starts from an already-released hold. + assert hold.status is ApprovalHoldStatus.RELEASED + assert hold.release_reason is ApprovalHoldReleaseReason.APPROVED_AND_SUBMITTED + released_at_before = hold.released_at + + with _owner_uow() as uow: + _service(uow).request_cancel(order_id, broker=broker) + uow.commit() + + with _owner_uow() as uow: + hold = uow.approval_holds.get_by_order_id(order_id) + assert hold is not None + assert hold.status is ApprovalHoldStatus.RELEASED + assert hold.release_reason is ApprovalHoldReleaseReason.APPROVED_AND_SUBMITTED + assert hold.released_at == released_at_before # request_cancel touched nothing + + _handler().handle( + { + "event": "canceled", + "execution_id": None, + "order": {"id": BROKER_ORDER_ID, "client_order_id": client_order_id}, + "timestamp": NOW.isoformat(), + } + ) + + with _owner_uow() as uow: + order = uow.orders.get_by_id(order_id) + hold = uow.approval_holds.get_by_order_id(order_id) + assert order is not None and order.status is OrderStatus.CANCELED + assert hold is not None + assert hold.status is ApprovalHoldStatus.RELEASED + assert hold.release_reason is ApprovalHoldReleaseReason.APPROVED_AND_SUBMITTED + assert hold.released_at == released_at_before # the confirmed cancel is a no-op release diff --git a/docs/decisions/25-order-cancellation.md b/docs/decisions/25-order-cancellation.md new file mode 100644 index 0000000..ca896a8 --- /dev/null +++ b/docs/decisions/25-order-cancellation.md @@ -0,0 +1,143 @@ +# 25 — Order cancellation is a broker request, never a local state mutation + +## Status + +Accepted + +## Context + +S3 §6 exposes create/approve/read on `/api/v1/orders` but no cancel route; `OrderStatus.CANCELED` +already exists in the state machine and `TERMINAL_NON_FILLED_STATUSES` (`app/models/orders/order.py`), +and `ApprovalHoldReleaseReason.CANCELED` already exists on `approval_hold` +(`app/models/orders/approval_hold.py`) — but both are currently inbound-only: nothing initiates a +cancellation, only a broker-confirmed `canceled` trade-update (ADR 22) ever produces one, via +`AlpacaTradeUpdateHandler` → `OrderProjectionService.apply_new_event` → `fold()`. + +Adding customer-initiated cancellation must not disturb that invariant. Alpaca's Trading API (ADR +21) accepts a cancel request but does not guarantee it: the order can still fill before the cancel +is processed broker-side. `BrokerPort` (`app/integrations/ports.py`) today only declares +`submit_order`; there is no `cancel_order`. + +## Decision + +### Cancellation is a request, not a transition + +`POST /api/v1/orders//cancel` calls `OrderService.request_cancel(order_id, broker=...)`, +which: + +1. Tenant-scoped `get_for_update` (identical ownership check to `approve`) — another customer's + order is invisible, not merely forbidden. +2. Rejects (`OrderNotCancellableError`, mapped to `409`) any order outside `_CANCELLABLE_STATUSES` + = `{submitted, accepted, partially_filled}` — i.e. an order already at the broker but not yet + in a terminal state. Pre-broker orders (`draft`/`awaiting_approval`/`approved`) have no broker + order to cancel; every terminal status (`filled`/`rejected`/`canceled`/`expired`) is, by + definition, no longer cancellable. +3. Reads the `broker_order_id` off the order's own `submitted` `order_event` payload (already + recorded by `submit_to_broker`'s `_synthesize_event`) and calls `BrokerPort.cancel_order(...)`. +4. **Never sets `order.status` and never touches the `approval_hold`.** The only path that ever + transitions an order to `canceled` is the existing one: Alpaca's `trade_updates` websocket + (ADR 22) → `AlpacaTradeUpdateHandler` → `OrderProjectionService.apply_new_event` → `fold()`. + `request_cancel` is deliberately symmetric with every other inbound-only terminal + transition — it can *ask*, it cannot *decide*. + +This means the broker may still fill the order after accepting the cancel request. That is treated +as a correct, expected outcome of asking rather than deciding, not a bug: whichever event lands +first on the websocket — `fill` or `canceled` — wins, exactly as `fold()` already handles any other +event ordering. + +### `BrokerPort.cancel_order` + +```python +def cancel_order(self, *, broker_order_id: str) -> None: ... +``` + +Added to the `BrokerPort` protocol (`app/integrations/ports.py`), implemented by +`AlpacaBrokerAdapter.cancel_order` (wraps the Alpaca SDK's `TradingClient.cancel_order_by_id`, +raising `alpaca.common.exceptions.APIError` uncaught — matching `submit_order`'s existing posture) +and by `FakeBrokerAdapter.cancel_order` (records the call in `self.cancel_requests`, the test +double's assertion surface). + +### Synchronous, not outbox-enqueued + +`request_cancel` calls the broker directly inside the `/cancel` request, not via `job_outbox` the +way `submit_order_to_broker` is. This mirrors this codebase's existing precedent for synchronous, +customer-facing provider calls — `identity.py`'s `_build_kyc_port`/`StripeKycAdapter` and +`funding.py`'s `_plaid_adapter`/`PlaidBankAdapter` are both constructed and called directly inside +their controllers, not queued. `_build_broker_port()` in `orders.py` follows the identical shape +(reads Alpaca credentials from settings, raises if unconfigured, monkeypatchable in tests) rather +than adding a new composition-root wiring pattern. + +### Idempotency + +A repeated cancel request while the order is still in `_CANCELLABLE_STATUSES` simply re-issues +`BrokerPort.cancel_order` — treated as a no-op success (`200`), since Alpaca's own cancel-order +endpoint is idempotent (canceling an order that already has a pending cancel does nothing extra). +No new "cancel already requested" column or state is introduced. Once the order leaves that set — +confirmed `canceled`, or raced into `filled`/any other terminal — a further request is a `409` +(`OrderNotCancellableError`), which is the natural boundary rather than a special-cased response. + +### Hold release is unchanged + +The buy-side `approval_hold` is already released the moment an order reaches `submitted` +(`ApprovalHoldReleaseReason.APPROVED_AND_SUBMITTED`, in `OrderProjectionService`). Since +`_CANCELLABLE_STATUSES` starts at `submitted`, every order this feature can cancel has *already* +had its hold released before a cancel is ever requested. `request_cancel` does not touch the hold; +`ApprovalHoldReleaseReason.CANCELED`'s release branch (already wired in +`OrderProjectionService._release_reason_by_terminal_status`) fires when the broker-confirmed +`canceled` event lands, but resolves as an idempotent no-op (`ApprovalHoldService.release` returns +immediately once a hold is already `released`) — correct, not dead code: it stays the live path for +any future cancellation of a pre-submission order, which is explicitly out of scope here (next +section). + +### Auditing + +The write is `@audited("orders.cancel_requested")`, via the same indirection-function pattern +`admin/kyc_overrides.py` already uses (`@audited` introspects a `uow` argument from the call frame, +which a bare Flask route handler doesn't expose without one). Note: `approve_order`'s route is +*not* currently `@audited` — this ADR does not retrofit it, but does not repeat its omission +either; every privileged write from this point forward should be, per the backend engineering +invariant, and `/cancel` is the first to comply. + +## Consequences + +- The fill-races-cancel case is a first-class, tested outcome: requesting a cancel on a + `submitted`/`accepted`/`partially_filled` order and then delivering a `fill` trade-update through + the existing handler still lands the order on `filled`, never `canceled` — no special-casing + needed, because `request_cancel` never wrote to `order.status` in the first place. +- `200` from `/cancel` means "the broker was asked to cancel", not "the order is canceled" — a + client must still read the order's `status` (or the event stream) to see the outcome. +- Pre-submission cancellation (`draft`/`awaiting_approval`/`approved`) is explicitly out of scope. + There is no broker order to cancel yet, so `BrokerPort.cancel_order` cannot apply; locally + transitioning such an order to `canceled` would need its own, separate decision (no broker call, + immediate hold release, since nothing is broker-visible yet) — a materially different, simpler + mechanism this ADR deliberately does not fold in. +- `orders.py`'s controller now imports `app.integrations.alpaca.broker_adapter` directly, which + `import-linter`'s `layers`/`services-use-ports-only` contracts permit (they constrain + `app.services`, not `app.controllers`) and which `funding.py`/`fees.py`/`identity.py` already do + for their own synchronous provider calls — consistent with, not a new exception to, the existing + architecture. + +## Alternatives considered + +- **Locally set `OrderStatus.CANCELED` at request time, correct it later if a fill arrives.** + Rejected outright: this is exactly the race the brief warns about ("Alpaca may still fill the + order after accepting the cancel request") and would require an un-cancel transition nothing else + in the state machine has, plus a customer-visible status that can silently reverse itself. +- **Enqueue an outbox task (`cancel_order_at_broker`), mirroring `submit_order_to_broker`.** + Considered for symmetry with submission. Rejected: submission is queued because it must + eventually succeed with retry semantics; a cancel request has no such requirement — a failed + attempt is safe for the client to retry idempotently, and the customer benefits more from a + synchronous "the broker has been asked" confirmation than from a fire-and-forget `202`. Kept as + the natural fallback if operational experience shows synchronous Alpaca calls from the request + path are too slow/unreliable. +- **A new "cancel requested" flag/timestamp column on `order`, for a distinct "already requested" + idempotency response.** Rejected: no observable behavior needs it — a repeated request while + still cancellable is indistinguishable, from the client's point of view, from the first request + (both mean "the broker has been (re-)asked"), and the broker's own idempotent cancel semantics + already cover the safety property. Adding state to distinguish "first" from "repeated" would be + tracking information nothing reads. +- **Allow pre-submission cancellation in the same endpoint, branching on status (broker call for + post-submission, local transition for pre-submission).** Rejected for this ADR: conflates two + different mechanisms (broker request vs. local mutation) behind one endpoint and one service + method, when the brief's explicit non-negotiable is specifically about the post-submission race. + Left as a clearly-scoped follow-on if product asks for it. From 3eb4a08381ab813169b58b01aae89ff96ddbda39 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 22:51:05 -0700 Subject: [PATCH 7/8] feat(profile): add customer profile storage and endpoints (ADR 27) 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. --- ...0b294386_add_profile_fields_to_customer.py | 36 ++ backend/app/__init__.py | 2 + backend/app/controllers/api/profile.py | 165 ++++++++ backend/app/models/identity/customer.py | 8 +- .../app/services/identity/profile_service.py | 57 +++ backend/app/views/profile.py | 21 ++ backend/tests/api/test_profile.py | 351 ++++++++++++++++++ docs/decisions/27-profile-personal-data.md | 126 +++++++ 8 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/20260906_2152-24a90b294386_add_profile_fields_to_customer.py create mode 100644 backend/app/controllers/api/profile.py create mode 100644 backend/app/services/identity/profile_service.py create mode 100644 backend/app/views/profile.py create mode 100644 backend/tests/api/test_profile.py create mode 100644 docs/decisions/27-profile-personal-data.md diff --git a/backend/alembic/versions/20260906_2152-24a90b294386_add_profile_fields_to_customer.py b/backend/alembic/versions/20260906_2152-24a90b294386_add_profile_fields_to_customer.py new file mode 100644 index 0000000..4c08818 --- /dev/null +++ b/backend/alembic/versions/20260906_2152-24a90b294386_add_profile_fields_to_customer.py @@ -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 ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 346e460..3818f05 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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 @@ -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) diff --git a/backend/app/controllers/api/profile.py b/backend/app/controllers/api/profile.py new file mode 100644 index 0000000..4246449 --- /dev/null +++ b/backend/app/controllers/api/profile.py @@ -0,0 +1,165 @@ +"""Profile routes (ADR 27): read/update the customer's own display name, phone, and mailing +address. Self-scoped only -- the customer is resolved from `flask.session["_user_id"]` +(`chat.py`'s `_current_customer_id` is the precedent this follows), never from a caller-supplied +`customer_id`; there is no such parameter on either route to check (unlike `funding.py`'s +`_resolve_customer_id_for_get`, which trusts a caller-supplied id for staff sessions -- that +pattern does not apply here since no staff/admin read path exists for this data, ADR 27). +""" + +from __future__ import annotations + +import re +import uuid +from typing import TYPE_CHECKING, Any + +from flask import Blueprint, jsonify, request +from flask import session as flask_session +from flask_login import current_user +from pydantic import BaseModel, field_validator +from pydantic import ValidationError as PydanticValidationError + +from app.core.errors import ForbiddenError, UnauthenticatedError, ValidationError +from app.core.security import audited +from app.core.uow import SessionRole +from app.extensions import limiter +from app.services.identity.profile_service import ProfileService, ProfileUpdate +from app.services.identity.uow import IdentityUnitOfWork +from app.views.profile import ProfileResponse + +if TYPE_CHECKING: + from app.models.identity.customer import Customer + +profile_bp = Blueprint("profile", __name__, url_prefix="/api/v1/profile") + +_PHONE_PATTERN = re.compile(r"^\+[1-9]\d{7,14}$") +"""E.164 (ADR 27): a leading '+', a non-zero first digit, 8-15 digits total.""" + +_MAX_DISPLAY_NAME_LENGTH = 200 +_MAX_MAILING_ADDRESS_LENGTH = 500 + + +def _validate_text_field(value: str | None, *, max_length: int) -> str | None: + """Shared `display_name`/`mailing_address` rule (ADR 27): `None` passes through unchanged (no + change requested, or an explicit clear); a supplied string must be 1..max_length characters + after stripping, so an empty or whitespace-only value is rejected rather than silently + stored. Stores the stripped form -- leading/trailing whitespace is never persisted.""" + if value is None: + return None + stripped = value.strip() + if not stripped: + raise ValueError("must not be empty or whitespace-only") + if len(stripped) > max_length: + raise ValueError(f"must be at most {max_length} characters") + return stripped + + +class ProfileUpdateRequest(BaseModel): + """`PATCH /api/v1/profile` body. Partial update (ADR 27): a field omitted here is left + unchanged; a field present with `null` clears it. The route reads `model_fields_set` to tell + "omitted" apart from "present and null" -- both are, of necessity, `None` on this model.""" + + display_name: str | None = None + phone: str | None = None + mailing_address: str | None = None + + @field_validator("display_name") + @classmethod + def _check_display_name(cls, value: str | None) -> str | None: + return _validate_text_field(value, max_length=_MAX_DISPLAY_NAME_LENGTH) + + @field_validator("mailing_address") + @classmethod + def _check_mailing_address(cls, value: str | None) -> str | None: + return _validate_text_field(value, max_length=_MAX_MAILING_ADDRESS_LENGTH) + + @field_validator("phone") + @classmethod + def _check_phone(cls, value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + if not _PHONE_PATTERN.match(stripped): + raise ValueError("must be E.164 format, e.g. +14155552671") + return stripped + + +def _current_customer_id() -> uuid.UUID: + """No `customer_id` parameter exists on either route -- the session is the only source of + truth (ADR 27).""" + if not current_user.is_authenticated: + raise UnauthenticatedError("Authentication required") + if current_user.role != "customer": + raise ForbiddenError("Profile is 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 _field_only_message(exc: PydanticValidationError) -> str: + """Field names only, never the submitted value (ADR 27, output-encoding discipline) -- + pydantic's own `str(exc)` embeds a repr of the invalid input, which this avoids entirely. + Populates `ValidationError.detail`, which is server-side only and never rendered to the + client (`AppError.to_problem()` does not read it) -- but the same discipline applies + regardless of where the message ends up.""" + fields = sorted({".".join(str(part) for part in err["loc"]) for err in exc.errors()}) + return f"Invalid value for field(s): {', '.join(fields)}" + + +@profile_bp.route("", methods=["GET"]) +@limiter.limit("60 per minute") +def get_profile() -> Any: + customer_id = _current_customer_id() + + with IdentityUnitOfWork(customer_id=customer_id, role=SessionRole.CUSTOMER) as uow: + customer = ProfileService(uow).get(customer_id) + view = ProfileResponse( + display_name=customer.display_name, + phone=customer.phone, + mailing_address=customer.mailing_address, + ) + + return jsonify(view.model_dump(mode="json")), 200 + + +@profile_bp.route("", methods=["PATCH"]) +@limiter.limit("10 per minute") # PII write: no looser than funding.py's money-movement writes. +def update_profile() -> Any: + customer_id = _current_customer_id() + try: + data = ProfileUpdateRequest.model_validate(request.get_json(silent=True) or {}) + except PydanticValidationError as exc: + raise ValidationError(_field_only_message(exc)) from exc + + update = ProfileUpdate(fields={name: getattr(data, name) for name in data.model_fields_set}) + + with IdentityUnitOfWork(customer_id=customer_id, role=SessionRole.CUSTOMER) as uow: + customer = ProfileService(uow).get(customer_id) + _apply_update(uow=uow, customer_id=customer_id, customer=customer, update=update) + uow.commit() + # Read the already-loaded object, not a second query: RLS's `app.customer_id` SET LOCAL + # does not outlive the transaction `commit()` just closed (kyc_overrides.py's same trap). + view = ProfileResponse( + display_name=customer.display_name, + phone=customer.phone, + mailing_address=customer.mailing_address, + ) + + return jsonify(view.model_dump(mode="json")), 200 + + +@audited("identity.profile.update") +def _apply_update( + *, + uow: IdentityUnitOfWork, + customer_id: uuid.UUID, + customer: Customer, + update: ProfileUpdate, +) -> None: + """Indirection so `@audited` sees `uow`/`customer_id` as its own arguments (matches + `kyc_overrides.py`'s `_apply_override`). `AdminAuditLog.payload_hash` stores a hash of the raw + request body, never the body itself (ADR 27) -- this never writes PII into the audit trail.""" + ProfileService(uow).apply_update(customer, update) + + +__all__ = ["profile_bp"] diff --git a/backend/app/models/identity/customer.py b/backend/app/models/identity/customer.py index f28fcb7..b688600 100644 --- a/backend/app/models/identity/customer.py +++ b/backend/app/models/identity/customer.py @@ -7,7 +7,7 @@ from enum import StrEnum from flask_login import UserMixin -from sqlalchemy import DDL, DateTime, event, text +from sqlalchemy import DDL, DateTime, String, event, text from sqlalchemy import Enum as SQLAlchemyEnum from sqlalchemy.orm import Mapped, mapped_column @@ -44,6 +44,12 @@ class Customer(Base, UserMixin): # type: ignore[misc, no-any-unimported] # fla DateTime(timezone=True), server_default=text("now()"), ) + # Profile PII (ADR 27): nullable, no default -- every pre-existing row predates these fields, + # and NULL means "never set," distinct from an explicitly-cleared empty string. Never logged, + # never in an @audited payload (app/core/logging.py, ADR 27) -- callers keep them out of calls. + display_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + phone: Mapped[str | None] = mapped_column(String(16), nullable=True) + mailing_address: Mapped[str | None] = mapped_column(String(500), nullable=True) @property def role(self) -> str: diff --git a/backend/app/services/identity/profile_service.py b/backend/app/services/identity/profile_service.py new file mode 100644 index 0000000..99d3553 --- /dev/null +++ b/backend/app/services/identity/profile_service.py @@ -0,0 +1,57 @@ +"""Customer profile read/update (ADR 27). Self-scoped only -- the controller resolves the +customer from the session; this service is never handed a caller-chosen customer_id to trust. +`display_name`/`phone`/`mailing_address` are never passed to a logger call here or anywhere else +(ADR 27's log-redaction rule).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from app.core.errors import NotFoundError + +if TYPE_CHECKING: + import uuid + from collections.abc import Mapping + + from app.models.identity.customer import Customer + from app.services.identity.uow import IdentityUnitOfWork + +_PROFILE_FIELDS = ("display_name", "phone", "mailing_address") + + +@dataclass(frozen=True) +class ProfileUpdate: + """Only the fields the caller actually supplied (ADR 27's partial-update semantics), keyed by + column name; a value of `None` means "clear this field," a key's absence means "leave it + untouched." Build from a validated request's `model_fields_set`, never from every declared + field, or an omitted field would be silently overwritten with `None`.""" + + fields: Mapping[str, str | None] = field(default_factory=dict) + + def is_set(self, name: str) -> bool: + return name in self.fields + + +class ProfileService: + """Reads and partially updates the profile fields owned by `/api/v1/profile` (ADR 27).""" + + def __init__(self, uow: IdentityUnitOfWork) -> None: + self._uow = uow + + def get(self, customer_id: uuid.UUID) -> Customer: + customer = self._uow.customers.get_by_id(customer_id) + if customer is None: # pragma: no cover - defensive; an authenticated session always + # corresponds to a persisted customer row. + raise NotFoundError("customer not found") + return customer + + def apply_update(self, customer: Customer, update: ProfileUpdate) -> None: + """Mutates an already-loaded `Customer` in place -- never re-queries. A post-commit + re-query would run in a fresh transaction with no `app.customer_id` `SET LOCAL` in + effect (that setting does not outlive the transaction it was set in), making the row + invisible to RLS; `kyc_overrides.py` avoids the same trap by building its response from + the object it already holds, not a second lookup.""" + for name in _PROFILE_FIELDS: + if update.is_set(name): + setattr(customer, name, update.fields[name]) diff --git a/backend/app/views/profile.py b/backend/app/views/profile.py new file mode 100644 index 0000000..54eb2d5 --- /dev/null +++ b/backend/app/views/profile.py @@ -0,0 +1,21 @@ +"""Response schema for `app/controllers/api/profile.py` (ADR 27, OWASP API3). + +Explicit allow-list only -- never a raw `Customer` entity (it carries `password_hash`) -- +`app/views/` may not import `app/models/`, enforced by `lint-imports`'s `views-are-not-entities` +contract. Deliberately narrow: only the fields `/profile` itself owns. The frontend gets +`id`/`email`/`role` from `/auth/session` and KYC/approval status from +`/identity/status/` separately -- this view does not duplicate either. +""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class ProfileResponse(BaseModel): + """`GET|PATCH /api/v1/profile` -- `None` means the field was never set (ADR 27), not that it + was cleared to empty; every pre-existing customer reads back with all three `None`.""" + + display_name: str | None + phone: str | None + mailing_address: str | None diff --git a/backend/tests/api/test_profile.py b/backend/tests/api/test_profile.py new file mode 100644 index 0000000..b969e37 --- /dev/null +++ b/backend/tests/api/test_profile.py @@ -0,0 +1,351 @@ +"""`GET|PATCH /api/v1/profile` (ADR 27) via the Flask test client: authn, self-scoping (no +`customer_id` parameter exists to manipulate), validation boundaries, audit-row redaction, and +throttling. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator + +import pytest +from flask.testing import FlaskClient +from sqlalchemy import Engine, select, text +from sqlalchemy.orm import Session + +from app.models.identity.customer import Customer +from app.models.ops.admin_audit_log import AdminAuditLog + +CUSTOMER_EMAIL = "profile-customer@trueup.example" +CUSTOMER_PASSWORD = "correct-horse-battery" +OTHER_EMAIL = "profile-other@trueup.example" + +_TABLES = [AdminAuditLog.__table__] + + +@pytest.fixture(autouse=True) +def _profile_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 +) -> 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 + + +# --- authentication -------------------------------------------------------------------------- + + +def test_get_profile_requires_authentication(api_client: FlaskClient) -> None: + response = api_client.get("/api/v1/profile") + + assert response.status_code == 401 + + +def test_patch_profile_requires_authentication(api_client: FlaskClient) -> None: + response = api_client.patch("/api/v1/profile", json={"display_name": "Jane Doe"}) + + assert response.status_code == 400 # CSRFProtect rejects an unauthenticated PATCH first. + + +# --- happy path / partial-update semantics ---------------------------------------------------- + + +def test_get_profile_starts_all_null(api_client: FlaskClient) -> None: + _, csrf_token = _register_and_login(api_client) + + response = api_client.get("/api/v1/profile", headers={"X-CSRFToken": csrf_token}) + + assert response.status_code == 200 + assert response.get_json() == { + "display_name": None, + "phone": None, + "mailing_address": None, + } + + +def test_patch_updates_only_supplied_fields(api_client: FlaskClient) -> None: + _, csrf_token = _register_and_login(api_client) + + first = api_client.patch( + "/api/v1/profile", + json={"display_name": "Jane Doe", "phone": "+14155552671"}, + headers={"X-CSRFToken": csrf_token}, + ) + assert first.status_code == 200 + assert first.get_json() == { + "display_name": "Jane Doe", + "phone": "+14155552671", + "mailing_address": None, + } + + second = api_client.patch( + "/api/v1/profile", + json={"mailing_address": "1 Market St, San Francisco, CA 94105"}, + headers={"X-CSRFToken": csrf_token}, + ) + + assert second.status_code == 200 + body = second.get_json() + # display_name/phone untouched by the second, mailing_address-only request. + assert body["display_name"] == "Jane Doe" + assert body["phone"] == "+14155552671" + assert body["mailing_address"] == "1 Market St, San Francisco, CA 94105" + + +def test_patch_clears_a_field_with_explicit_null(api_client: FlaskClient) -> None: + _, csrf_token = _register_and_login(api_client) + api_client.patch( + "/api/v1/profile", + json={"display_name": "Jane Doe"}, + headers={"X-CSRFToken": csrf_token}, + ) + + response = api_client.patch( + "/api/v1/profile", + json={"display_name": None}, + headers={"X-CSRFToken": csrf_token}, + ) + + assert response.status_code == 200 + assert response.get_json()["display_name"] is None + + +def test_patch_strips_surrounding_whitespace(api_client: FlaskClient) -> None: + _, csrf_token = _register_and_login(api_client) + + response = api_client.patch( + "/api/v1/profile", + json={"display_name": " Jane Doe "}, + headers={"X-CSRFToken": csrf_token}, + ) + + assert response.status_code == 200 + assert response.get_json()["display_name"] == "Jane Doe" + + +# --- validation boundaries --------------------------------------------------------------------- + + +@pytest.mark.parametrize("value", ["", " "]) +def test_patch_rejects_empty_or_whitespace_only_display_name( + api_client: FlaskClient, value: str +) -> None: + _, csrf_token = _register_and_login(api_client) + + response = api_client.patch( + "/api/v1/profile", json={"display_name": value}, headers={"X-CSRFToken": csrf_token} + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "validation_failed" + + +@pytest.mark.parametrize("value", ["", " "]) +def test_patch_rejects_empty_or_whitespace_only_mailing_address( + api_client: FlaskClient, value: str +) -> None: + _, csrf_token = _register_and_login(api_client) + + response = api_client.patch( + "/api/v1/profile", json={"mailing_address": value}, headers={"X-CSRFToken": csrf_token} + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "validation_failed" + + +def test_patch_accepts_null_display_name_as_no_op_clear(api_client: FlaskClient) -> None: + """`null` (unlike `""`/whitespace) is a valid, explicit "clear" -- not a validation error.""" + _, csrf_token = _register_and_login(api_client) + + response = api_client.patch( + "/api/v1/profile", json={"display_name": None}, headers={"X-CSRFToken": csrf_token} + ) + + assert response.status_code == 200 + assert response.get_json()["display_name"] is None + + +def test_patch_rejects_overlong_display_name(api_client: FlaskClient) -> None: + _, csrf_token = _register_and_login(api_client) + + response = api_client.patch( + "/api/v1/profile", + json={"display_name": "x" * 201}, + headers={"X-CSRFToken": csrf_token}, + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "validation_failed" + + +def test_patch_rejects_overlong_mailing_address(api_client: FlaskClient) -> None: + _, csrf_token = _register_and_login(api_client) + + response = api_client.patch( + "/api/v1/profile", + json={"mailing_address": "x" * 501}, + headers={"X-CSRFToken": csrf_token}, + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "validation_failed" + + +@pytest.mark.parametrize( + "value", + [ + "not-a-phone", + "14155552671", # missing leading + + "+0123456789", # leading digit after + is 0 + "+1234", # too short + "+1" + "2" * 20, # too long + "+1 415 555 2671", # embedded whitespace + ], +) +def test_patch_rejects_malformed_phone(api_client: FlaskClient, value: str) -> None: + _, csrf_token = _register_and_login(api_client) + + response = api_client.patch( + "/api/v1/profile", json={"phone": value}, headers={"X-CSRFToken": csrf_token} + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "validation_failed" + + +def test_patch_accepts_valid_e164_phone(api_client: FlaskClient) -> None: + _, csrf_token = _register_and_login(api_client) + + response = api_client.patch( + "/api/v1/profile", json={"phone": "+14155552671"}, headers={"X-CSRFToken": csrf_token} + ) + + assert response.status_code == 200 + assert response.get_json()["phone"] == "+14155552671" + + +# --- self-scoping (no customer_id parameter exists to manipulate) ------------------------------ + + +def test_get_profile_never_sees_another_customers_data(api_client: FlaskClient) -> None: + _, a_csrf = _register_and_login(api_client, email=CUSTOMER_EMAIL) + api_client.patch( + "/api/v1/profile", + json={"display_name": "Customer A", "phone": "+14155552671"}, + headers={"X-CSRFToken": a_csrf}, + ) + api_client.post("/api/v1/auth/logout", headers={"X-CSRFToken": a_csrf}) + + _, b_csrf = _register_and_login(api_client, email=OTHER_EMAIL) + response = api_client.get("/api/v1/profile", headers={"X-CSRFToken": b_csrf}) + + assert response.status_code == 200 + assert response.get_json() == {"display_name": None, "phone": None, "mailing_address": None} + + +def test_patch_by_one_customer_never_affects_another( + api_client: FlaskClient, db_committing: Session +) -> None: + a_id, a_csrf = _register_and_login(api_client, email=CUSTOMER_EMAIL) + api_client.patch( + "/api/v1/profile", + json={"display_name": "Customer A"}, + headers={"X-CSRFToken": a_csrf}, + ) + api_client.post("/api/v1/auth/logout", headers={"X-CSRFToken": a_csrf}) + + _, b_csrf = _register_and_login(api_client, email=OTHER_EMAIL) + # No customer_id field exists on the request schema; even smuggling one in the body changes + # nothing -- the route resolves the target from the session alone. + response = api_client.patch( + "/api/v1/profile", + json={"display_name": "Customer B", "customer_id": a_id}, + headers={"X-CSRFToken": b_csrf}, + ) + assert response.status_code == 200 + + customer_a = db_committing.execute( + select(Customer).where(Customer.id == uuid.UUID(a_id)) + ).scalar_one() + assert customer_a.display_name == "Customer A" + + +# --- audit row: change recorded, PII never persisted into it ----------------------------------- + + +def test_patch_writes_an_audit_row_without_the_raw_pii( + api_client: FlaskClient, db_committing: Session +) -> None: + customer_id, csrf_token = _register_and_login(api_client) + + response = api_client.patch( + "/api/v1/profile", + json={"display_name": "Jane Doe", "phone": "+14155552671"}, + headers={"X-CSRFToken": csrf_token}, + ) + assert response.status_code == 200 + + rows = db_committing.execute(select(AdminAuditLog)).scalars().all() + assert len(rows) == 1 + row = rows[0] + assert row.action == "identity.profile.update" + assert row.target_customer_id == uuid.UUID(customer_id) + assert row.payload_hash != "" + assert len(row.payload_hash) == 64 # sha256 hex digest -- a hash, not the payload itself. + + row_repr = f"{row.action}|{row.payload_hash}|{row.target_customer_id}|{row.actor_id}" + assert "Jane Doe" not in row_repr + assert "+14155552671" not in row_repr + + +# --- redaction: PII in a rejected request never reaches the response body --------------------- + + +def test_validation_error_never_echoes_the_submitted_pii(api_client: FlaskClient) -> None: + _, csrf_token = _register_and_login(api_client) + secret_value = "SuperSecretPiiValue12345" + + response = api_client.patch( + "/api/v1/profile", + json={"display_name": secret_value * 20}, # over the 200-char bound + headers={"X-CSRFToken": csrf_token}, + ) + + assert response.status_code == 422 + assert secret_value not in response.get_data(as_text=True) + + +# --- throttling -------------------------------------------------------------------------------- + + +def test_patch_profile_is_throttled(api_client: FlaskClient) -> None: + _, csrf_token = _register_and_login(api_client) + + last_response = None + for _ in range(11): + last_response = api_client.patch( + "/api/v1/profile", + json={"display_name": "Jane Doe"}, + headers={"X-CSRFToken": csrf_token}, + ) + + assert last_response is not None + assert last_response.status_code == 429 + assert "Retry-After" in last_response.headers diff --git a/docs/decisions/27-profile-personal-data.md b/docs/decisions/27-profile-personal-data.md new file mode 100644 index 0000000..530ce45 --- /dev/null +++ b/docs/decisions/27-profile-personal-data.md @@ -0,0 +1,126 @@ +# 27 — Customer profile personal data: storage, access, and validation + +## Status + +Accepted + +## Context + +`Customer` (`backend/app/models/identity/customer.py`) has never stored a name, phone number, or +address — real identity data has deliberately lived only inside Stripe Identity's hosted KYC flow +(ADR 9); only the pass/fail verdict round-trips into Trueup's own database +(`backend/app/services/identity/kyc_service.py`). The frontend now needs a Profile page that shows +and edits more than a bare email address, which means storing the platform's first genuinely +human-identity PII columns. No spec or ADR had picked what gets stored, who can read it, how it's +validated, or how it interacts with root `CLAUDE.md`'s "never log a secret or PII" rule — so this +ADR settles that before the schema changes. + +## Decision + +### What's stored + +Three nullable columns added to `customer`: `display_name`, `phone`, `mailing_address`. Nullable +with no default, because every existing customer row predates these fields and login/session reads +must not break for them — a `NULL` means "never set," not an empty string (an autogenerated default +that backfilled `""` would be indistinguishable from "customer explicitly cleared this field"). + +Nothing beyond these three. No SSN, no date of birth, no government ID data — those remain +Stripe Identity's responsibility (ADR 9) and are never persisted in Trueup's own tables. + +### Read access: self-only + +`GET /api/v1/profile` and `PATCH /api/v1/profile` resolve the customer entirely from the session +(`flask.session["_user_id"]`, following `chat.py`'s `_current_customer_id` pattern) — no +`customer_id` request parameter of any kind, so there is no ownership check to get wrong and no +staff/admin bypass. No staff/admin read path exists for this data in this change; if adviser access +to a customer's profile is wanted later, that is a separate, separately-audited decision (it would +need its own justification against the minimum-necessary-access principle, not an incidental +side-effect of this one). + +### Never logged, never in an audit payload + +`app/core/logging.py` documents the platform's existing mechanism for this: structured logging via +`structlog`, with the stated contract that "secrets and PII never reach a log — this module is the +plumbing; callers keep them out of calls." There is no field-name redaction registry in this +codebase — `password_hash` is kept out of logs the same way: no code path ever passes it to a +logger call or serializes it into a response. Profile PII follows the identical discipline: no +`log.*()` call in the profile service or controller ever receives `display_name`, `phone`, or +`mailing_address` as an argument, structured or otherwise. + +The `@audited` decorator (`app/core/security.py`, ADR 15) already enforces this structurally for +audit rows: `AdminAuditLog.payload_hash` stores a SHA-256 hash of the request body, never the body +itself. `PATCH /api/v1/profile` is `@audited("identity.profile.update")`, which records that a +change happened (actor, target customer, timestamp, payload hash) without ever writing the new +name/phone/address values into `admin_audit_log`. + +Validation errors reference the field name only, never the submitted value — an error message never +echoes attacker-controlled input back into the response body (output-encoding discipline, root +`CLAUDE.md` security section). + +### Retention + +No special retention policy. These columns follow the `customer` row's own lifecycle exactly — no +separate TTL, no separate deletion job, no separate export/erasure path beyond whatever governs the +`customer` record as a whole. Stated explicitly so it is not ambiguous later: profile PII does not +get a bespoke retention rule invented in this change. + +### Validation rules + +- `display_name`: 1–200 characters after stripping leading/trailing whitespace; rejects + empty-string and whitespace-only input (`None`/omitted is fine — that's "unset," distinct from + "set to nothing"). +- `mailing_address`: 1–500 characters after stripping leading/trailing whitespace; same + empty/whitespace-only rejection. Free-text single field (street/city/state/zip/country all in + one) — matches the fact that Trueup validates *identity* through Stripe Identity, not mailing + address deliverability; no structured address service is introduced by this change. +- `phone`: **E.164** format, exactly — `^\+[1-9]\d{7,14}$` (a leading `+`, then 8–15 digits total, + first digit non-zero, per ITU-T E.164's own constraint that a subscriber number never starts with + `0` after the country code). Chosen because it's the one unambiguous machine-checkable phone + format that needs no per-country parsing library or new dependency, and it's what any future SMS/ + voice provider integration would require verbatim. + +Partial update: `PATCH /api/v1/profile` changes only the fields present in the request body: a +present field with a value updates it, a present field explicitly set to `null` clears it, an +absent field is left untouched. No PUT-style full-replace — matches the "safer default for a +profile form" the task called for, and no other PATCH-shaped write exists yet in this codebase to +conflict with. + +### Rate limit + +`PATCH /api/v1/profile` gets `@limiter.limit("10 per minute")` — the same conservative bound +`funding.py` and `identity.py` apply to every state-changing write in this codebase (`/bank-links`, +`/deposits`, `/withdrawals`, `/kyc-sessions` are all `10 per minute`); a profile edit is no higher- +frequency an action than any of those, and PII changes get no looser a bound than money movement +does. `GET /api/v1/profile` gets `60 per minute`, matching other self-scoped `GET`s +(`/auth/session`, `/funding/history`). + +## Consequences + +- `customer` gains three nullable `String` columns; every pre-existing row reads back with all + three `None`, which the view renders as `null` — the frontend must treat `null` as "not yet + provided," not as an error state. +- A PII change is now an auditable event (`identity.profile.update`), consistent with every other + privileged/self-service write in this codebase, without adding a second parallel logging/audit + mechanism. +- Any future adviser-facing read of this data is out of scope here and requires its own ADR — + this decision does not implicitly grant it. + +## Alternatives considered + +- **Free-form phone validation (any string).** Rejected: a completely unvalidated phone field is + useless to any future SMS/voice integration and invites obviously-malformed data with no + server-side signal. E.164 is the smallest constraint that keeps the field meaningful. +- **A `phonenumbers`-library-backed per-country validator.** Rejected for this change as + disproportionate: it adds a new dependency and per-region parsing complexity for a field with no + current downstream consumer beyond display; E.164's own format constraint is sufficient and this + can be revisited if a provider integration later needs stricter per-region validation. +- **PUT-style full-replace instead of partial update.** Rejected: no other PATCH-shaped write exists + in this codebase to match, and full-replace would force the frontend to always resend every field + (including ones the customer isn't editing) just to avoid accidentally clearing them. +- **Allowing staff/admin read in this same change.** Rejected: the task and this ADR scope this to + self-only; an adviser-facing read is a distinct, separately-audited access-control decision, not a + natural extension to bundle in here. +- **Structured address fields (street/city/state/zip/country columns).** Rejected for this change: + no downstream consumer (shipping, tax jurisdiction, etc.) needs structured address data yet, and + a single free-text field is simplest to satisfy the stated need (show/edit *something* beyond + email). Revisit if a future feature needs structured address data. From d96b0884206c19899e5537c64b29ec4a0bed0501 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sun, 6 Sep 2026 22:51:16 -0700 Subject: [PATCH 8/8] docs: changelog entries and ADR index for the page redesign programme Summarizes the Fees, Tax Lots, Funding, Portfolio, Orders, and Profile work in CHANGELOG.md; indexes ADRs 25-27 in docs/decisions/README.md. --- CHANGELOG.md | 43 ++++++++++++++++++++++++++++++++++++++++ docs/decisions/README.md | 3 +++ 2 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb122e8..a0826ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 46a8590..dfc678b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -31,6 +31,9 @@ consequences, and the alternatives it rejected. | [22](22-alpaca-trade-updates-websocket-intake.md) | Alpaca fills arrive over a `trade_updates` websocket, not an HTTP webhook | Accepted | | [23](23-key-vault-envelope-encryption.md) | Azure Key Vault envelope encryption for field-level secrets at rest | Accepted | | [24](24-mcp-agent-surface-approval-queue.md) | MCP agent surface: read-only tools plus a durable, adviser-approved write queue | Accepted | +| [25](25-order-cancellation.md) | Order cancellation is a broker request, never a local state mutation | Accepted | +| [26](26-portfolio-performance-series.md) | Portfolio performance series sourced from `sub_period_return`, live vs. as-published, allowlisted range | Accepted | +| [27](27-profile-personal-data.md) | Customer profile personal data: storage, access, and validation | Accepted | Still open, deferred to the S8 sub-project (see [`docs/architecture.md`](../architecture.md)): customer surface (mobile vs. web) and adviser console vs. plain admin.