GET /api/v1/portfolio/properties — Мои объекты (getMyProperties)
Назначение
Постраничный список объектов портфеля текущего инвестора — основа страницы /profile/my-lots
(«Мои объекты недвижимости»). Для каждого объекта: снапшот объекта недвижимости, за сколько купили
(PurchasePrice, PurchasedAt), за сколько продали и сколько заработали (SalePrice, SoldAt,
RealizedProfit — для Sold), текущая оценка и нереализованный доход (read-проекция от
invest-analytics — для Active). Дом полей — ../../domain/investor-property.md;
общие модели (MoneyModel, PropertyObjectSnapshotModel, CurrentValuationModel) и правило деградации
оценки — README.md.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | investor-owner |
| API area | public |
| Feature group | portfolio |
| Статус | draft |
| Документ | docs/06-services/investor-owner/api/public/get-my-properties.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 = claim sub; условие владельца дублируется явно в запросе (defence-in-depth, README.md) |
| Tenant isolation | не применяется — инвестор вне tenant (ADR-0052) |
| Audit | read-sensitive: финансовые данные отдаются только владельцу; audit-запись не создаётся |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | GET |
| Route | /api/v1/portfolio/properties |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
InvestorUserId | identity claims (sub) | Guid | Да | валидный JWT (подпись, exp, notBefore) — 401 до операции | record-level ключ; в query/route не принимается |
Status | query | OwnershipStatus? | Нет | enum Active / Sold; неизвестное значение → ValidationError | — |
Page | query | int | Нет | >= 1, default 1 | — |
PageSize | query | int | Нет | 1..100, default 20 (портфель — единицы-десятки записей, ADR-0056) | — |
Sort | query | string? | Нет | purchasedAt / createdAt / soldAt (+ префикс - = desc), default -purchasedAt; иное значение → ValidationError | — |
Модель ответа GetMyPropertiesResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
items | IReadOnlyList<MyPropertyListItemModel> | Да | проекция | — | Страница объектов портфеля. |
page | int | Да | echo запроса | — | Текущая страница. |
pageSize | int | Да | echo запроса | — | Размер страницы. |
totalCount | long | Да | подсчёт по фильтру | — | Всего записей под фильтром. |
MyPropertyListItemModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
id | Guid | Да | InvestorPropertyEntity.Id | — | Id записи портфеля (ключ карточки get-my-property.md). |
propertyObjectId | Guid | Да | InvestorPropertyEntity.PropertyObjectId | — | Объект недвижимости (chessboard); ключ истории цен invest-analytics. |
projectId | Guid? | Нет | InvestorPropertyEntity.ProjectId | — | Проект; null — объект Talent Owners не сопоставлен с шахматкой PIN. |
status | string (enum OwnershipStatus) | Да | InvestorPropertyEntity.Status | — | Active / Sold. |
source | string (enum PropertySource) | Да | InvestorPropertyEntity.Source | — | PinCrmDeal / TalentOwnersSync. |
propertyObject | PropertyObjectSnapshotModel | Да | VO PropertyObjectSnapshot (README.md) | — | Снапшот объекта недвижимости на момент покупки. |
purchasePrice | MoneyModel | Да | PurchasePrice | только владельцу | За сколько купили. |
purchasedAt | string (ISO 8601) | Да | PurchasedAt | — | Когда купили. |
salePrice | MoneyModel? | Нет (обязателен при status = Sold, INV-3) | SalePrice | только владельцу | За сколько продали. |
soldAt | string (ISO 8601)? | Нет (обязателен при status = Sold) | SoldAt | — | Когда продали (= когда заработали). |
realizedProfit | MoneyModel? | Нет (обязателен при status = Sold) | RealizedProfit | только владельцу | Сколько заработали (может быть < 0 — убыток). |
currentValuation | CurrentValuationModel | Да | invest-analytics + computed (README.md) | только владельцу | Текущая оценка и нереализованный доход; available = false для Sold, projectId = null и при деградации. |
createdAt | string (ISO 8601) | Да | CreatedAt | — | Появление записи в портфеле. |
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (
InvestorUserId— из claimsub); требуется permissioninvestor-owner.portfolio.view-self(иначе403); scope self. Выдаются только собственные записи (record-levelInvestorUserId = actor). - Проверка входа.
Page >= 1,PageSize ∈ 1..100,Status∈ enum,Sort∈ допустимому списку → иначеValidationError(400). Пустой портфель — валидный ответ сtotalCount = 0. - Что читаем. Страница записей портфеля инвестора (не удалённых); при заданном
Status— фильтр по статусу; сортировка поSort. Тем же запросом возвращаетсяtotalCount. Затем для активных записей сProjectId != null— один батч-запрос текущих оценок вinvest-analytics(getLotsCurrentPrices, timeout 500 мс, без retry; сбой/таймаут → пустой словарь оценок, правило деградации — README.md). Порядок: сначала страница (определяетPropertyObjectId-ы), затем батч оценок. - Маппинг. Поле-в-поле — таблица
MyPropertyListItemModel.currentValuationдля каждого item: если оценка получена —available = true,currentPrice = {NewPrice, CurrencyId},pricedAt = EventTime,unrealizedProfit.amount = currentPrice.amount − PurchasePrice.Amount(только при совпадении валют —currentPrice.currencyId = PurchasePrice.CurrencyId, иначеunrealizedProfit = null); иначе —available = false, остальные поляnull.RealizedProfitне пересчитывается — читается как зафиксирован при продаже (INV-5). - Результат.
200. Консистентность: записи портфеля — read-your-writes;currentValuation— eventual (freshness ленты ценinvest-analytics≤ 5 мин + допустимая деградация доavailable = false).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Внешние вызовы | invest-analytics getLotsCurrentPrices (батч ≤ PageSize объектов недвижимости, timeout 500 мс, без retry, best-effort — README.md) |
| Проекции / поиск | none (btree-индексы ix_investor_properties_investor_user_id, ix_investor_properties_status, ix_investor_properties_purchased_at; ADR-0056 — без Redis) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
| — (auth) | 401 | Нет/просрочен/невалиден JWT | без тела схемы (фронт очищает сессию) | re-login |
ValidationError | 400 | Некорректные Page/PageSize/Status/Sort | Список ошибок полей | no retry |
FORBIDDEN | 403 | Нет investor-owner.portfolio.view-self | «Недостаточно прав» | no retry |
| — (деградация, не ошибка) | 200 | invest-analytics недоступен/timeout | currentValuation.available = false у затронутых items | фронт может перезапросить |
Совместимость и наблюдаемость
- Новые optional поля item-модели — additive; изменение семантики
currentValuationили enum-значений — breaking (правило — README.md). - Logs:
InvestorUserId, фильтры, размер страницы, флаг деградации оценки — без сумм; metrics:investor_owner_my_properties_requests_total{result}, latency histogram,investor_owner_current_valuation_degraded_total; traces: сквозной trace (вложенный span вызоваinvest-analytics); dashboardinvestor-owner-overview.
Acceptance Criteria
- Given инвестор с записями в портфеле, When
GET /portfolio/propertiesбез фильтров, Then200со страницей-purchasedAt(defaultPage=1,PageSize=20),totalCount= число не-удалённых записей владельца; в выдаче — только записи сInvestorUserId = sub. - Given пустой портфель (или все записи под фильтром отсутствуют), When запрос, Then
200сitems = [],totalCount = 0(не404). - Given
Status = Active, When запрос, Then возвращаются толькоActive-записи; для каждой сProjectId != nullcurrentValuation.available = trueпри полученной оценке,unrealizedProfit.amount = currentPrice − PurchasePriceтолько при совпадении валют (иначеunrealizedProfit = null). - Given
Status = Sold, When запрос, Then записи содержатsalePrice/soldAt/realizedProfit(INV-3),currentValuation.available = false(для проданных оценка не запрашивается). - Given запись
Activeбез сопоставления с шахматкой (projectId = null), When запрос, Then оценка не запрашивается иcurrentValuation.available = false. - Given
invest-analyticsнедоступен/таймаут (500 мс), When запрос, Then200, у затронутых itemscurrentValuation.available = false, реализованные показатели (realizedProfit) не затронуты, инкрементinvestor_owner_current_valuation_degraded_total. - Given
Page < 1,PageSize ∉ 1..100, неизвестныйStatusилиSortвне списка, When запрос, ThenValidationError(400) без обращения к хранилищу. - Given отсутствует
investor-owner.portfolio.view-self, When запрос, Then403; Given просроченный/невалидный JWT, Then401до операции.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: InvestorPropertyEntity
- API (3): GET /api/v1/portfolio/properties/{propertyId} — Карточка объекта портфеля (getMyProperty), GET /api/v1/portfolio/summary — Сводка доходности портфеля (getMyPortfolioSummary), Investor-Owner API — реестр операций и общие контрактные модели
- Use Cases (1): UC-OWN-003. Инвестор просматривает портфель «Мои объекты недвижимости» и доходность
- registries (1): Реестр API-методов PIN
Связанные документы
- Реестр и общие модели: README.md.
- Сущность: ../../domain/investor-property.md (дом полей, INV-1..9).
- Карточка: get-my-property.md; сводка: get-my-portfolio-summary.md.
- Текущая оценка: ../../../invest-analytics/api/get-lots-current-prices.md (assumption); лента цен — ../../../invest-analytics/domain/price-history-fact.md.