GET /api/v1/portfolio/summary — Сводка доходности портфеля (getMyPortfolioSummary)
Назначение
Сводка доходности портфеля текущего инвестора для шапки страницы /profile/my-lots: суммарно —
сколько всего вложено, за сколько продано, сколько заработано (Σ RealizedProfit по Sold), текущая
стоимость активной части и нереализованный доход; по объекту — компактная разбивка «за сколько
купили / за сколько продали / сколько и когда заработали» по каждой записи. Дом полей —
../../domain/investor-property.md. Общие модели (MoneyModel,
CurrentValuationModel) и правило деградации оценки — README.md.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | investor-owner |
| API area | public |
| Feature group | portfolio |
| Статус | draft |
| Документ | docs/06-services/investor-owner/api/public/get-my-portfolio-summary.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (инвестор, Bearer JWT) |
| Auth policy / Permissions | authenticated; permission investor-owner.portfolio.view-self (../../../identity/domain/permission.md) |
| Scope (RBAC) | self (роль не требуется) |
| Record-level | агрегируются только собственные записи: InvestorUserId = sub |
| Tenant isolation | не применяется — инвестор вне tenant (ADR-0052) |
| Audit | read-sensitive: агрегированные финансовые данные — только владельцу; audit-запись не создаётся |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | GET |
| Route | /api/v1/portfolio/summary |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
Body/route/query-параметров нет.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
InvestorUserId | identity claims (sub) | Guid | Да | валидный JWT (подпись, exp, notBefore) — 401 до операции | record-level ключ |
Модель ответа GetMyPortfolioSummaryResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
totalCount | int | Да | computed: число не-удалённых записей | — | Всего объектов в портфеле. |
activeCount | int | Да | computed: Count(Status = Active) | — | Объектов в собственности. |
soldCount | int | Да | computed: Count(Status = Sold) | — | Проданных объектов. |
totals | IReadOnlyList<PortfolioCurrencyTotalsModel> | Да | computed (группировка по PurchasePrice.CurrencyId) | только владельцу | Суммарные показатели; v1 — один элемент (RUB, ADR-0048), контейнер — задел мультивалютности. Пустой портфель → пустой список. |
items | IReadOnlyList<PortfolioPropertyProfitModel> | Да | проекция + computed | только владельцу | Разбивка доходности «по объекту» (весь портфель без пагинации — единицы-десятки записей, ADR-0056), сортировка -purchasedAt. |
PortfolioCurrencyTotalsModel (суммы — по записям c данным PurchasePrice.CurrencyId)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
currencyId | short | Да | ключ группировки — ссылка на справочник валют handbook (../../../handbook/domain/currency.md) | — | Валюта всех сумм модели (типизированная ссылка). |
currencyIsoCode | string | Да | денормализованный CurrencyIsoCode записей группы (3 символа, ISO-4217; снят при записи) | — | Display-код валюты группы — для подписи сумм без резолва справочника. |
investedTotal | MoneyModel | Да | computed: Σ PurchasePrice по всем записям группы (Active + Sold) | только владельцу | Сколько всего вложено за время работы с PIN. |
activeInvestedTotal | MoneyModel | Да | computed: Σ PurchasePrice по Active | только владельцу | Вложено в объекты, которыми владеет сейчас. |
soldAmountTotal | MoneyModel | Да | computed: Σ SalePrice по Sold | только владельцу | За сколько суммарно продали. |
realizedProfitTotal | MoneyModel | Да | computed: Σ RealizedProfit по Sold (INV-3 гарантирует заполненность; может быть < 0) | только владельцу | Сколько всего заработали (реализованный доход). |
currentValueTotal | MoneyModel? | Нет | computed: Σ currentPrice по Active c полученной оценкой; null, если оценённых объектов нет | только владельцу | Текущая стоимость оценённой активной части. |
unrealizedProfitTotal | MoneyModel? | Нет | computed: Σ (currentPrice − PurchasePrice) по тем же записям; null вместе с currentValueTotal | только владельцу | Нереализованный доход (не хранится — read-проекция). |
pricedActiveCount | int | Да | computed | — | Сколько Active-объектов вошло в currentValueTotal (для честной подписи «оценено N из M» при деградации/несопоставленных объектах). |
PortfolioPropertyProfitModel (дельта от get-my-properties.md: без снапшота объекта недвижимости целиком)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
id | Guid | Да | InvestorPropertyEntity.Id | — | Ключ карточки get-my-property.md. |
propertyObjectId | Guid | Да | PropertyObjectId | — | Объект недвижимости. |
title | string | Да | PropertyObjectSnapshot.Title | — | Название объекта. |
projectName | string? | Нет | PropertyObjectSnapshot.Project?.ProjectName | — | Название проекта (компактная разбивка; id проекта — в карточке). |
status | string (enum OwnershipStatus) | Да | Status | — | Active / Sold. |
purchasePrice | MoneyModel | Да | PurchasePrice | только владельцу | За сколько купили. |
purchasedAt | string (ISO 8601) | Да | PurchasedAt | — | Когда купили. |
salePrice | MoneyModel? | Нет (обязателен при status = Sold) | SalePrice | только владельцу | За сколько продали. |
soldAt | string (ISO 8601)? | Нет (обязателен при status = Sold) | SoldAt | — | Когда заработали. |
realizedProfit | MoneyModel? | Нет (обязателен при status = Sold) | RealizedProfit | только владельцу | Сколько заработали по объекту. |
currentValuation | CurrentValuationModel | Да | invest-analytics + computed (README.md) | только владельцу | Текущая оценка/нереализованный доход (available = false для Sold / несопоставленных / деградации). |
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (
InvestorUserId— из claimsub); требуется permissioninvestor-owner.portfolio.view-self(иначе403); scope self. Агрегируются только собственные записи. - Проверка входа. Входных полей формы нет. Пустой портфель — валидный ответ: нули в counts, пустые
totals/items. - Что читаем. Весь портфель актора (не удалённые записи), сортировка
-PurchasedAt; без пагинации осознанно (единицы-десятки записей, ADR-0056), защитный предел выборки 1000 записей (превышение — инфраструктурная аномалия, алерт). Затем для активных записей сProjectId != null— один батч-запрос текущих оценок вinvest-analytics(getLotsCurrentPrices, timeout 500 мс, без retry; сбой → пустой словарь, правило деградации — README.md). Порядок: сначала весь портфель (определяет объекты для оценки), затем батч оценок. - Расчёты. (decimal, без округления — суммы уже
numeric(18,2)):items[i].currentValuation— по правилу README.md (как в списке);- группировка записей по
PurchasePrice.CurrencyId→ для каждой группыPortfolioCurrencyTotalsModel(currencyId— ключ;currencyIsoCode— денормализованный display-код записей группы):investedTotal = Σ PurchasePrice.Amount;activeInvestedTotal = ΣпоStatus = Active;soldAmountTotal = Σ SalePrice.AmountпоStatus = Sold;realizedProfitTotal = Σ RealizedProfit.AmountпоSold(значения читаются зафиксированными при продаже — INV-3/INV-5, не пересчитываются);currentValueTotal = Σ currentPriceпоActivec оценкой в валюте группы (currentPrice.currencyId = currencyIdгруппы);unrealizedProfitTotal = currentValueTotal − Σ PurchasePrice.Amountпо тем же записям;pricedActiveCount= число таких записей; оценка в валюте, отличной от валюты покупки (CurrencyIdне совпадает), в суммы группы не включается (запись остаётся вactiveCount, но не вpricedActiveCount); - counts:
totalCount= число записей;activeCount/soldCount— поStatus.
- Результат.
200. Консистентность: записи портфеля — read-your-writes;currentValueTotal/unrealizedProfitTotal— eventual (freshness ленты цен ≤ 5 мин; при деградации —null+pricedActiveCountпоказывает покрытие).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Внешние вызовы | invest-analytics getLotsCurrentPrices (один батч по активным сопоставленным объектам недвижимости, timeout 500 мс, без retry, best-effort) |
| Проекции / поиск | none: агрегация on-read по ix_investor_properties_investor_user_id (ADR-0056 — без Redis и материализованных сводок; предвычисление появится только при доказанной деградации latency) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
| — (auth) | 401 | Нет/просрочен/невалиден JWT | без тела схемы | re-login |
FORBIDDEN | 403 | Нет investor-owner.portfolio.view-self | «Недостаточно прав» | no retry |
| — (деградация, не ошибка) | 200 | invest-analytics недоступен/timeout | currentValueTotal = null, unrealizedProfitTotal = null, pricedActiveCount = 0; реализованные показатели полны | фронт может перезапросить |
Кода 404 у операции нет: пустой портфель — валидный 200 c нулями/пустыми списками.
Совместимость и наблюдаемость
- Новые optional поля — additive; изменение формулы
realizedProfitTotal/unrealizedProfitTotal— breaking для фронта (согласование с продуктом + rollout); появление второй валюты не ломает контракт (totals— список). - Logs:
InvestorUserId, counts, флаг деградации — без сумм; metrics:investor_owner_portfolio_summary_requests_total{result}, latency histogram,investor_owner_current_valuation_degraded_total; traces: сквозной trace + вложенный spaninvest-analytics; dashboardinvestor-owner-overview.
Acceptance Criteria
- Given инвестор с активными и проданными записями в одной валюте, When
GET /portfolio/summary, Then200с корректными counts (totalCount/activeCount/soldCount), одним элементомtotals(currencyIdвалютыRUB,currencyIsoCode = "RUB"):investedTotal = Σ PurchasePrice(Active+Sold),realizedProfitTotal = Σ RealizedProfitпоSold, иitemsпо всему портфелю (-purchasedAt, без пагинации). - Given пустой портфель, When запрос, Then
200с нулями в counts и пустымиtotals/items(кода404нет). - Given часть
Active-записей оценена, часть — нет (деградация/несопоставленные), When запрос, ThencurrentValueTotal/unrealizedProfitTotalсчитаются только по оценённым,pricedActiveCountотражает покрытие «оценено N из M». - Given полная недоступность
invest-analytics, When запрос, Then200,currentValueTotal = null,unrealizedProfitTotal = null,pricedActiveCount = 0; реализованные показатели (investedTotal/soldAmountTotal/realizedProfitTotal) полны и не затронуты. - Given активная запись с оценкой в валюте, отличной от валюты покупки (
currencyIdоценки ≠CurrencyIdзаписи), When запрос, Then запись учтена вactiveCount, но не вpricedActiveCountи не в суммах группы. - Given нет
investor-owner.portfolio.view-self, When запрос, Then403; невалидный JWT —401.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: InvestorPropertyEntity
- API (3): GET /api/v1/portfolio/properties — Мои объекты (getMyProperties), GET /api/v1/portfolio/properties/{propertyId} — Карточка объекта портфеля (getMyProperty), Investor-Owner API — реестр операций и общие контрактные модели
- Use Cases (1): UC-OWN-003. Инвестор просматривает портфель «Мои объекты недвижимости» и доходность
- registries (1): Реестр API-методов PIN
Связанные документы
- Реестр и общие модели: README.md.
- Сущность: ../../domain/investor-property.md (дом полей; INV-3/INV-5 —
фиксация
RealizedProfitпри продаже). - Список: get-my-properties.md; карточка: get-my-property.md.
- Текущая оценка: ../../../invest-analytics/api/get-lots-current-prices.md (assumption); лента цен — ../../../invest-analytics/domain/price-history-fact.md.