feat: redesign portfolio position cards

This commit is contained in:
2026-08-17 13:34:31 +03:00
parent aac1b435af
commit 6795ee6f96
6 changed files with 227 additions and 2 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-17
@@ -0,0 +1,58 @@
## Context
См. proposal.md — Why/What. Текущее состояние: `PositionSection` в `AccountPage.view.tsx:269` рендерит полноширинные `Card`-строки на колонке 960px; позиции уже делятся на секции Акции/Облигации/Другие в upstream-порядке. Backend уже тянет metadata инструмента для каждой позиции (name/ticker) с 24-часовым кэшем; T-Bank в ответе `GetInstrumentBy` дополнительно отдаёт `isin`, но backend его не пробрасывает (`TbankInstrument` типизирован как `{ ticker?, name? }`, `tbank.client.ts:47`).
Важный факт: `totalsByCurrency` включает **и** Cash Balance (`portfolio.service.ts:148` — `new Map(cash)`), поэтому он непригоден как знаменатель доли позиции.
## Goals / Non-Goals
**Goals:**
- Backend отдаёт `isin` позиции без новых обращений к T-Bank.
- Frontend отображает позиции компактными плитками в grid с метриками: идентификатор, количество, `currentValue` (главная), `absoluteReturn` (цвет знака), `averagePurchasePrice`, доля среди позиций той же валюты, `isin`. Null-метрики скрываются.
- Сохранить секции, upstream-порядок, некликабельность, пустые/ошибки/stale-состояния и ровно один способ расчёта доли.
**Non-Goals:**
- Относительная доходность в %, тикер, сортировка, кликабельные карточки, изменение Totals/Cash/«Последних событий», валютная конвертация.
## Decisions
### 1. ISIN прокидывается через существующий metadata-конвейер
`TbankInstrument` расширяется полем `isin?: string`; `position()` в `portfolio.service.ts` возвращает `isin: metadata?.isin?.trim() || null`; `PortfolioPositionDto` получает nullable `isin`. Новых RPC нет — metadata уже запрашивается и кэшируется, меняется только форма значения.
*Альтернатива:* отдельный endpoint инструментов — overkill; маппинг на фронте — невозможно (ISIN нет в данных).
### 2. Доля позиции считается на клиенте, знаменатель — только позиции одной валюты
Знаменатель = Σ `currentValue` **позиций** одной валюты, без Cash Balance. Сумма вычисляется во `view` (или VM) по всему массиву `positions` один раз (Map валюта → сумма) и передаётся в плитки. Важно: доля считается **по всем секциям сразу**, а не внутри секции — иначе USD-облигация и USD-акция считались бы раздельно.
*Альтернатива:* знаменатель из `totalsByCurrency` — отклонено: он включает кэш и занижает долю. Backend-расчёт — отклонено: доля это отображаемая метрика, спека закрепляет её за клиентом.
### 3. Расчёт доли — display-only, точность Number с округлением
`display.ts` получает helper `formatShare(numerator, denominator)` → строка процента (1 знак) или `null` при недоступности. Значения money — decimal-строки; для отображения доли достаточно `Number(value)` и округления: доля не участвует в финансовой арифметике и не передаётся в агрегаты. Один маленький `assert`-тест в `display.test.ts`.
*Risk/mitigation:* значения крупнее 2^53 теряют точность — неприменимо к реальным суммам портфеля и безвредно для display-only.
### 4. Grid — Mantine `SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}`
Тот же компонент, что уже используется для Totals-карточек. Плитки заменяют `Card`-строки в `PositionSection`; на узком экране — одна колонка (спека «Портфель отображается на мобильном устройстве» остаётся верной).
### 5. Структура плитки
- Верх: `displayName` (усечение), `isin` (dim, `mono`, xs) под именем, количество (dim, sm).
- Справа/ниже: `currentValue` крупно (главная метрика), `absoluteReturn` с цветом знака.
- Низ: `averagePurchasePrice` и доля (dim, xs).
### 6. Цвета — Mantine-переменные
Зелёный `var(--mantine-color-green-6)` / красный `var(--mantine-color-red-6)` для `absoluteReturn`; без цвета при `null`. Кастомную палитру не вводим.
### 7. Null-метрики скрываются
Вместо «—» строка не рендерится (компактная плитка). Это меняет текущий тест `AccountPage.test.tsx:94` (`expect(screen.getByText("—"))`).
## Risks / Trade-offs
- [Точность доли при Number] → доля display-only, округление до 1 знака; upgrade до BigInt-деления, если появится сценарий, требующий точности.
- [ISIN отсутствует у валют и части инструментов] → nullable; плитка скрывает строку ISIN.
- [Grid-плитки на узком экране] → `base: 1` гарантирует одноколоночность.
- [«—»-заглушка в тестах] → обновить `AccountPage.test.tsx` на проверку скрытия метрик.
## Migration Plan
Обратимая аддитивная миграция. Backend-поле `isin` nullable: старый frontend игнорирует лишнее поле; старый backend без `isin` даёт `undefined` → плитка скрывает строку. Откат — revert соответствующего коммита в репозитории. Снапшот OpenAPI в frontend обновляется `npm run openapi:sync` после изменения backend.
## Open Questions
Нет. Детали типографики и отступов плитки решаются на этапе реализации без изменения спек.
@@ -0,0 +1,27 @@
## Why
Position Card на странице Account Portfolio выглядит вытянутой: полноширинный ряд на колонке 960px с данными только по краям и пустой серединой. Показываются всего четыре метрики, хотя API уже отдаёт `averagePurchasePrice`, а ISIN инструмента доступен у T-Bank, но не пробрасывается backend-ом. Карточка не даёт ответить на «сколько эта позиция весит в портфеле» и «по какой цене куплена» с первого взгляда.
## What Changes
- **Backend**: `PortfolioPositionDto` получает nullable `isin` из metadata инструмента (T-Bank уже возвращает `isin` в ответах `GetInstrumentBy`); тип `TbankInstrument` расширяется полем `isin`.
- **Frontend**: Position Card перестаёт быть полноширинной строкой и становится компактной плиткой в grid (`base: 1, sm: 2, md: 3`). Плитка показывает: `displayName`, количество, `currentValue` (главная метрика), `absoluteReturn` (цвет зелёный/красный), `averagePurchasePrice`, долю позиции среди позиций той же валюты, ISIN (dim, моноширинный, под именем). Null-метрики скрываются, а не отображаются как «—».
- **Не меняется**: upstream-порядок внутри секций, разбиение на секции Акции/Облигации/Другие, Totals-карточки, Cash Balance, «Последние события», остальные страницы. Плитки некликабельны. Относительная доходность в % и тикер не добавляются.
- **Документация**: `CONTEXT.md` получает термин Instrument; `docs/features/portfolio-view.md` обновляет описание отображения позиций и поля `isin`.
## Capabilities
### New Capabilities
- (нет новых возможностей — изменение затрагивает только существующий portfolio-view)
### Modified Capabilities
- `portfolio-view`: Portfolio Position получает nullable `isin`; клиент отображает позиции компактными плитками в grid с расширенным составом метрик (средняя цена покупки, доля среди позиций той же валюты, ISIN), скрывает отсутствующие метрики и раскрашивает доход.
## Impact
- **backend** (`/api/v1/brokerage-accounts/:accountId/portfolio`): `PortfolioPositionDto` + поле `isin`; тип `TbankInstrument`; маппинг в `portfolio.service.ts`; тесты `portfolio.service.spec.ts`.
- **frontend**: OpenAPI snapshot и `schema.d.ts` (`openapi:sync`), `display.ts` (расчёт доли), `AccountPage.view.tsx`, `AccountPage.module.css`, i18n `ru.ts`, тесты `AccountPage.test.tsx` и `display.test.ts`.
- **docs**: `CONTEXT.md`, `docs/features/portfolio-view.md`.
- Внешних зависимостей и breaking-изменений нет: `isin` добавляется как nullable-поле.
@@ -0,0 +1,77 @@
## MODIFIED Requirements
### Requirement: Нормализованный состав Portfolio
Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Порядок Portfolio Position и Cash Balance MUST соответствовать upstream-порядку; система MUST NOT сортировать или объединять Portfolio Position с одинаковым FIGI. Виртуальные позиции и заблокированные денежные остатки MUST NOT входить в Portfolio.
Каждая Portfolio Position MUST содержать нормализованный тип `STOCK`, `BOND`, `FUND`, `CURRENCY`, `FUTURE`, `OPTION`, `STRUCTURED_NOTE`, `DIGITAL_FINANCIAL_ASSET` либо `OTHER`, строковое `quantity`, nullable `currentValue`, nullable `averagePurchasePrice`, nullable `absoluteReturn` и nullable `isin`. Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения.
#### Scenario: Позиция с неизвестным upstream-типом
- **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента
- **THEN** система возвращает её в исходном порядке с типом `OTHER`
#### Scenario: Позиция без metadata инструмента
- **WHEN** lookup metadata по FIGI не дал ticker или name либо временно недоступен
- **THEN** система возвращает позицию с FIGI как fallback-идентификатором и всеми доступными финансовыми значениями
#### Scenario: Имя инструмента доступно
- **WHEN** metadata инструмента по FIGI содержит непустое name
- **THEN** система возвращает name как отображаемый идентификатор позиции вместо FIGI или ticker
#### Scenario: FIGI не разрешается, но доступен instrument UID
- **WHEN** FIGI позиции не даёт metadata, а её instrument UID возвращает непустое name
- **THEN** система возвращает name, полученное по instrument UID
#### Scenario: ISIN инструмента доступен
- **WHEN** metadata инструмента позиции содержит непустой `isin`
- **THEN** система возвращает его в поле `isin` позиции
#### Scenario: ISIN инструмента отсутствует
- **WHEN** у инструмента позиции нет ISIN либо metadata недоступна
- **THEN** система возвращает позицию с `isin: null`, не скрывая финансовые значения
#### Scenario: Денежные остатки в одной валюте
- **WHEN** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты
- **THEN** система возвращает один Cash Balance с их точной суммой
#### Scenario: Штатный пустой Portfolio
- **WHEN** у доступного Brokerage Account нет ненулевых обычных позиций и доступных денежных остатков
- **THEN** система отвечает `200` с пустыми `positions`, `cashBalances` и `totalsByCurrency`
### Requirement: Ошибки и состояния Portfolio на клиенте
Клиент SHALL загружать Portfolio при открытии защищённого маршрута `/accounts/:accountId`. Он MUST использовать успешный ответ в течение 30 секунд, без automatic retry, polling или refetch при возврате фокуса. После истечения срока клиент MUST скрыть предыдущий ответ и показать loading-состояние до нового результата.
Клиент MUST показать штатное empty-состояние только при пустых `positions` и `cashBalances`. При `isStale: true` клиент MUST показывать Snapshot, `lastSuccessfulAt` и нейтральное предупреждение о временной недоступности T-Bank. При `404` клиент MUST показать состояние недоступного счёта с действием возврата к списку счетов. При отсутствии Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE` или иной ошибке клиент MUST показать безопасное error-состояние с доступными для ошибки действиями; действие повтора разрешено только для временной и неизвестной ошибки.
Клиент MUST отображать `STOCK` и `BOND` отдельными секциями. Внутри каждой секции MUST сохраняться upstream-порядок позиций. Позиции иных типов MUST оставаться доступными в отдельной общей секции. Position Card MUST отображаться как компактная плитка в адаптивном grid: одна колонка на узком экране и несколько колонок на широком. Плитка MUST показывать отображаемый идентификатор позиции, количество, `currentValue` как главную метрику, `absoluteReturn` с цветовой индикацией знака, `averagePurchasePrice`, долю позиции среди позиций той же валюты и `isin`. Отсутствующие метрики MUST быть скрыты, а не отображаться заглушкой. При форматировании денежных значений в русской локали клиент MUST отображать RUB как символ `₽`; другие валюты MUST сохранять понятное обозначение.
#### Scenario: Показ устаревшего Portfolio
- **WHEN** клиент получает успешный ответ с `isStale: true`
- **THEN** клиент показывает позиции и остатки, время последнего успешного снимка и предупреждение без действия ручного обновления
#### Scenario: Недоступный счёт на клиенте
- **WHEN** клиент получает `404` для Portfolio
- **THEN** клиент не показывает Portfolio и предлагает вернуться к списку Brokerage Account без действия повтора
#### Scenario: Портфель отображается на мобильном устройстве
- **WHEN** User открывает загруженный Portfolio на узком экране
- **THEN** клиент сохраняет читаемое одноколоночное отображение агрегатов, позиций и Cash Balance
#### Scenario: Отдельные акции и облигации
- **WHEN** Portfolio содержит позиции `STOCK` и `BOND`
- **THEN** клиент отображает их в отдельных секциях «Акции» и «Облигации» без изменения порядка в каждой секции
#### Scenario: Карточка позиции отображает доступные метрики
- **WHEN** позиция имеет значение текущей стоимости, абсолютного дохода, средней цены покупки и ISIN
- **THEN** клиент показывает идентификатор, количество, текущую стоимость, абсолютный доход, среднюю цену покупки, долю среди позиций той же валюты и ISIN
#### Scenario: Отсутствующие метрики скрыты
- **WHEN** у позиции отсутствует значение текущей стоимости, средней цены покупки или дохода
- **THEN** клиент скрывает соответствующую метрику вместо отображения заглушки
#### Scenario: Доход окрашен по знаку
- **WHEN** у позиции абсолютный доход положительный или отрицательный
- **THEN** клиент отображает его зелёным для положительного и красным для отрицательного, без цвета при его отсутствии
#### Scenario: Доля позиции в валюте
- **WHEN** позиция имеет вычислимую текущую стоимость в валюте
- **THEN** клиент отображает её долю среди всех позиций той же валюты, не включая Cash Balance
@@ -0,0 +1,37 @@
## 1. Backend: expose ISIN
- [x] 1.1 Расширить тип `TbankInstrument` полем `isin?: string` в `tbank.client.ts`
- [x] 1.2 Добавить nullable `isin` в `PortfolioPositionDto` в `portfolio.dto.ts`
- [x] 1.3 Возвращать `isin: metadata?.isin?.trim() || null` в `position()` в `portfolio.service.ts`
- [x] 1.4 Обновить `portfolio.service.spec.ts`: моки и ожидания `isin`, включая отсутствие ISIN
- [x] 1.5 Проверить backend: `npm run lint`, `npm test`, `npm run build`
## 2. Frontend: OpenAPI contract и display-helper
- [x] 2.1 Обновить snapshot и `schema.d.ts`: `npm run openapi:sync` (поле `isin`)
- [x] 2.2 Добавить helper `formatShare(numerator, denominator)` в `display.ts` (строка %, 1 знак, `null` при недоступности)
- [x] 2.3 Добавить unit-тесты `formatShare` в `display.test.ts`
## 3. Frontend: Position Card grid-плитки
- [x] 3.1 Заменить `Card`-строки в `PositionSection` на `SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}` в `AccountPage.view.tsx`
- [x] 3.2 Вычислить во view сумму `currentValue` позиций по каждой валюте (без Cash Balance) и передать её плиткам
- [x] 3.3 Рендерить плитку: имя, `isin` (mono, dim), количество, `currentValue`, `absoluteReturn` (зелёный/красный), `averagePurchasePrice`, долю; скрывать null-метрики
- [x] 3.4 Добавить стили плитки в `AccountPage.module.css`
- [x] 3.5 Добавить i18n-ключи в `ru.ts` (средняя цена покупки, доля, ISIN)
## 4. Frontend: тесты
- [x] 4.1 Обновить `AccountPage.test.tsx`: заменить проверку «—» на скрытие null, добавить проверки плиток, `isin`, доли и сохранения upstream-порядка
- [x] 4.2 Проверить frontend: `npm run verify`
## 5. Документация
- [x] 5.1 Добавить термин Instrument в `CONTEXT.md`
- [x] 5.2 Обновить `docs/features/portfolio-view.md`: grid-плитки, метрики, `isin`, доля; API-секция про `isin`
## 6. Верификация и архив
- [x] 6.1 Прогнать `opsx-verify` по change
- [ ] 6.2 Архив change через `openspec archive` и синхронизация delta в main spec
- [ ] 6.3 Закоммитить по репозиториям: specs, docs, backend, frontend
+26 -2
View File
@@ -20,7 +20,7 @@
### Requirement: Нормализованный состав Portfolio ### Requirement: Нормализованный состав Portfolio
Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Порядок Portfolio Position и Cash Balance MUST соответствовать upstream-порядку; система MUST NOT сортировать или объединять Portfolio Position с одинаковым FIGI. Виртуальные позиции и заблокированные денежные остатки MUST NOT входить в Portfolio. Система MUST возвращать только обычные Portfolio Position с ненулевым количеством и доступные Cash Balance с ненулевой суммой. Cash Balance MUST быть отдельным от Portfolio Position элементом и агрегироваться до одного значения на валюту. Порядок Portfolio Position и Cash Balance MUST соответствовать upstream-порядку; система MUST NOT сортировать или объединять Portfolio Position с одинаковым FIGI. Виртуальные позиции и заблокированные денежные остатки MUST NOT входить в Portfolio.
Каждая Portfolio Position MUST содержать нормализованный тип `STOCK`, `BOND`, `FUND`, `CURRENCY`, `FUTURE`, `OPTION`, `STRUCTURED_NOTE`, `DIGITAL_FINANCIAL_ASSET` либо `OTHER`, строковое `quantity`, nullable `currentValue`, nullable `averagePurchasePrice` и nullable `absoluteReturn`. Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения. Каждая Portfolio Position MUST содержать нормализованный тип `STOCK`, `BOND`, `FUND`, `CURRENCY`, `FUTURE`, `OPTION`, `STRUCTURED_NOTE`, `DIGITAL_FINANCIAL_ASSET` либо `OTHER`, строковое `quantity`, nullable `currentValue`, nullable `averagePurchasePrice`, nullable `absoluteReturn` и nullable `isin`. Система MUST искать metadata по instrument UID при его наличии, затем по FIGI. Отображаемый идентификатор MUST предпочитать name, затем ticker, а при недоступности metadata использовать FIGI, не скрывая финансовые значения.
#### Scenario: Позиция с неизвестным upstream-типом #### Scenario: Позиция с неизвестным upstream-типом
- **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента - **WHEN** upstream возвращает обычную ненулевую позицию с нераспознанным типом инструмента
@@ -38,6 +38,14 @@
- **WHEN** FIGI позиции не даёт metadata, а её instrument UID возвращает непустое name - **WHEN** FIGI позиции не даёт metadata, а её instrument UID возвращает непустое name
- **THEN** система возвращает name, полученное по instrument UID - **THEN** система возвращает name, полученное по instrument UID
#### Scenario: ISIN инструмента доступен
- **WHEN** metadata инструмента позиции содержит непустой `isin`
- **THEN** система возвращает его в поле `isin` позиции
#### Scenario: ISIN инструмента отсутствует
- **WHEN** у инструмента позиции нет ISIN либо metadata недоступна
- **THEN** система возвращает позицию с `isin: null`, не скрывая финансовые значения
#### Scenario: Денежные остатки в одной валюте #### Scenario: Денежные остатки в одной валюте
- **WHEN** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты - **WHEN** upstream возвращает несколько ненулевых доступных денежных остатков одной валюты
- **THEN** система возвращает один Cash Balance с их точной суммой - **THEN** система возвращает один Cash Balance с их точной суммой
@@ -89,7 +97,7 @@
Клиент MUST показать штатное empty-состояние только при пустых `positions` и `cashBalances`. При `isStale: true` клиент MUST показывать Snapshot, `lastSuccessfulAt` и нейтральное предупреждение о временной недоступности T-Bank. При `404` клиент MUST показать состояние недоступного счёта с действием возврата к списку счетов. При отсутствии Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE` или иной ошибке клиент MUST показать безопасное error-состояние с доступными для ошибки действиями; действие повтора разрешено только для временной и неизвестной ошибки. Клиент MUST показать штатное empty-состояние только при пустых `positions` и `cashBalances`. При `isStale: true` клиент MUST показывать Snapshot, `lastSuccessfulAt` и нейтральное предупреждение о временной недоступности T-Bank. При `404` клиент MUST показать состояние недоступного счёта с действием возврата к списку счетов. При отсутствии Snapshot и `TBANK_NOT_CONNECTED`, `INVALID_TBANK_TOKEN`, `TBANK_UNAVAILABLE` или иной ошибке клиент MUST показать безопасное error-состояние с доступными для ошибки действиями; действие повтора разрешено только для временной и неизвестной ошибки.
Клиент MUST отображать `STOCK` и `BOND` отдельными секциями. Внутри каждой секции MUST сохраняться upstream-порядок позиций. Позиции иных типов MUST оставаться доступными в отдельной общей секции. При форматировании денежных значений в русской локали клиент MUST отображать RUB как символ `₽`; другие валюты MUST сохранять понятное обозначение. Клиент MUST отображать `STOCK` и `BOND` отдельными секциями. Внутри каждой секции MUST сохраняться upstream-порядок позиций. Позиции иных типов MUST оставаться доступными в отдельной общей секции. Position Card MUST отображаться как компактная плитка в адаптивном grid: одна колонка на узком экране и несколько колонок на широком. Плитка MUST показывать отображаемый идентификатор позиции, количество, `currentValue` как главную метрику, `absoluteReturn` с цветовой индикацией знака, `averagePurchasePrice`, долю позиции среди позиций той же валюты и `isin`. Отсутствующие метрики MUST быть скрыты, а не отображаться заглушкой. При форматировании денежных значений в русской локали клиент MUST отображать RUB как символ `₽`; другие валюты MUST сохранять понятное обозначение.
#### Scenario: Показ устаревшего Portfolio #### Scenario: Показ устаревшего Portfolio
- **WHEN** клиент получает успешный ответ с `isStale: true` - **WHEN** клиент получает успешный ответ с `isStale: true`
@@ -106,3 +114,19 @@
#### Scenario: Отдельные акции и облигации #### Scenario: Отдельные акции и облигации
- **WHEN** Portfolio содержит позиции `STOCK` и `BOND` - **WHEN** Portfolio содержит позиции `STOCK` и `BOND`
- **THEN** клиент отображает их в отдельных секциях «Акции» и «Облигации» без изменения порядка в каждой секции - **THEN** клиент отображает их в отдельных секциях «Акции» и «Облигации» без изменения порядка в каждой секции
#### Scenario: Карточка позиции отображает доступные метрики
- **WHEN** позиция имеет значение текущей стоимости, абсолютного дохода, средней цены покупки и ISIN
- **THEN** клиент показывает идентификатор, количество, текущую стоимость, абсолютный доход, среднюю цену покупки, долю среди позиций той же валюты и ISIN
#### Scenario: Отсутствующие метрики скрыты
- **WHEN** у позиции отсутствует значение текущей стоимости, средней цены покупки или дохода
- **THEN** клиент скрывает соответствующую метрику вместо отображения заглушки
#### Scenario: Доход окрашен по знаку
- **WHEN** у позиции абсолютный доход положительный или отрицательный
- **THEN** клиент отображает его зелёным для положительного и красным для отрицательного, без цвета при его отсутствии
#### Scenario: Доля позиции в валюте
- **WHEN** позиция имеет вычислимую текущую стоимость в валюте
- **THEN** клиент отображает её долю среди всех позиций той же валюты, не включая Cash Balance