From 19a924647e0c215df20c40a7430e714a7102dcfd Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 28 Aug 2026 23:41:32 +0300 Subject: [PATCH 1/4] fix: count matched broker cash withdrawals --- CHANGELOG.md | 6 ++++++ backend/package.json | 2 +- backend/src/services/portfolio.ts | 3 ++- backend/src/services/portfolioOverview.test.ts | 1 + package-lock.json | 2 +- 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c22204..5b25348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Backend 0.15.3] - 2026-08-28 + +### Fixed + +- Recognize confirmed broker withdrawals even when the broker description does not contain the word «вывод». + ## [Backend 0.15.2] - 2026-08-28 ### Fixed diff --git a/backend/package.json b/backend/package.json index 272f67e..c6ff3f0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.15.2", + "version": "0.15.3", "private": true, "scripts": { "dev": "tsx watch src/app.ts", diff --git a/backend/src/services/portfolio.ts b/backend/src/services/portfolio.ts index 548b3d6..22bb02f 100644 --- a/backend/src/services/portfolio.ts +++ b/backend/src/services/portfolio.ts @@ -40,7 +40,8 @@ export function classifyPortfolioCash(amount: number, description: string, match const text = description.toLowerCase().replace(/ё/g, 'е'); if (amount > 0 && /дивиденд|купон|процент/.test(text)) return 'income'; if (amount > 0 && (matchedExternalTransfer || /пополнение/.test(text)) && !text.includes('под нерассчитанные сделки')) return 'contribution'; - if (amount < 0 && /вывод|перевод денежных средств|перечисление денежных средств|возврат денежных средств/.test(text) && (!text.includes('под нерассчитанные сделки') || matchedExternalTransfer)) return 'withdrawal'; + if (amount < 0 && matchedExternalTransfer) return 'withdrawal'; + if (amount < 0 && /вывод|перевод денежных средств|перечисление денежных средств|возврат денежных средств/.test(text) && !text.includes('под нерассчитанные сделки')) return 'withdrawal'; return 'other'; } diff --git a/backend/src/services/portfolioOverview.test.ts b/backend/src/services/portfolioOverview.test.ts index a4bdf35..2f01ffa 100644 --- a/backend/src/services/portfolioOverview.test.ts +++ b/backend/src/services/portfolioOverview.test.ts @@ -33,5 +33,6 @@ assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных средств для приобретения ценных бумаг', true), 'contribution'); assert.equal(classifyPortfolioCash(52000, 'Пополнение брокерского счёта'), 'contribution'); assert.equal(classifyPortfolioCash(-129000, 'Вывод ДС под нерассчитанные сделки', true), 'withdrawal'); +assert.equal(classifyPortfolioCash(-129000, 'Сальдо расчетов по сделкам с ценными бумагами', true), 'withdrawal'); assert.equal(sumKnownPortfolioValuations([100, null, 25]), 125); console.log('portfolio cash classification: OK'); diff --git a/package-lock.json b/package-lock.json index 2e18857..2125541 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "backend": { "name": "@family-budget/backend", - "version": "0.15.2", + "version": "0.15.3", "dependencies": { "@family-budget/shared": "*", "cookie-parser": "^1.4.7", From 22e9656e929d81dc0e4015cf589562c5d512f56b Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 28 Aug 2026 23:52:53 +0300 Subject: [PATCH 2/4] fix: reconcile split broker withdrawals --- CHANGELOG.md | 6 +++ backend/package.json | 2 +- backend/src/services/portfolio.ts | 40 +++++++++++++------ .../src/services/portfolioOverview.test.ts | 8 +++- package-lock.json | 2 +- 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b25348..a969798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Backend 0.15.4] - 2026-08-28 + +### Fixed + +- Reconcile split broker withdrawals with one aggregated transfer to a family account. + ## [Backend 0.15.3] - 2026-08-28 ### Fixed diff --git a/backend/package.json b/backend/package.json index c6ff3f0..e9e9f9a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.15.3", + "version": "0.15.4", "private": true, "scripts": { "dev": "tsx watch src/app.ts", diff --git a/backend/src/services/portfolio.ts b/backend/src/services/portfolio.ts index 22bb02f..8281341 100644 --- a/backend/src/services/portfolio.ts +++ b/backend/src/services/portfolio.ts @@ -30,7 +30,7 @@ type PortfolioHistoryRow = { total_valuation: string | number | null; }; -type PerformanceCashRow = { id: number | string; account_id: number | string; alias: string | null; bank: string; account_number: string; account_type: string | null; operation_at: string; amount_signed: number | string; description: string }; +export type PerformanceCashRow = { id: number | string; account_id: number | string; alias: string | null; bank: string; account_number: string; account_type: string | null; operation_at: string; amount_signed: number | string; description: string }; type PerformanceTradeRow = { account_id: number | string; side: string; quantity: string | number; settlement_amount: string | number | null; settlement_commission: string | number | null; trade_commission: string | number | null; isin: string | null; instrument: string }; type PerformancePositionRow = { account_id: number | string; quantity: string | number; valuation: string | number | null; isin: string | null; instrument: string }; @@ -50,6 +50,31 @@ export function sumKnownPortfolioValuations(values: Array): numbe return known.length > 0 ? known.reduce((sum, value) => sum + value, 0) : null; } +export function matchExternalCashTransfers(rows: PerformanceCashRow[]): Set { + const broker = rows.filter((row) => ['brokerage', 'iis'].includes(row.account_type ?? '')); + const external = rows.filter((row) => !['brokerage', 'iis'].includes(row.account_type ?? '')); + const matchedBroker = new Set(); + const matchedExternal = new Set(); + for (const target of external) { + const targetAmount = Math.round(Number(target.amount_signed)); + if (!targetAmount || matchedExternal.has(target.id)) continue; + const candidates = broker.filter((row) => !matchedBroker.has(row.id) && Math.sign(Number(row.amount_signed)) === -Math.sign(targetAmount) && Math.abs(Math.round(Number(row.amount_signed))) <= Math.abs(targetAmount) && Math.abs(Date.parse(row.operation_at) - Date.parse(target.operation_at)) <= 3 * 24 * 60 * 60 * 1000 && /вывод|сальдо расчетов|перевод|перечисление/.test(row.description.toLowerCase())); + const exact = candidates.find((row) => Math.round(Number(row.amount_signed)) === -targetAmount); + let group: PerformanceCashRow[] = exact ? [exact] : []; + if (group.length === 0) { + for (let index = 0; index < candidates.length && group.length === 0; index += 1) { + for (const second of candidates.slice(index + 1)) { + if (Math.round(Number(candidates[index].amount_signed)) + Math.round(Number(second.amount_signed)) === -targetAmount) { group = [candidates[index], second]; break; } + } + } + } + if (group.length === 0) continue; + matchedExternal.add(target.id); + group.forEach((row) => matchedBroker.add(row.id)); + } + return matchedBroker; +} + function decimalValue(value: unknown, field: string, required = false): string | null { if (value == null || value === '') { if (required) throw new Error(`${field} is required`); @@ -336,21 +361,12 @@ export async function getPortfolioPerformance(): Promise(); - const matchedExternalTransfers = new Set(); - const externalCash = cashResult.rows.filter((row) => !['brokerage', 'iis'].includes(row.account_type ?? '')); - const hasExternalTransfer = (row: PerformanceCashRow): boolean => { - const target = -Number(row.amount_signed); - const at = Date.parse(row.operation_at); - const match = externalCash.find((candidate) => !matchedExternalTransfers.has(candidate.id) && Number(candidate.amount_signed) === target && Math.abs(Date.parse(candidate.operation_at) - at) <= 3 * 24 * 60 * 60 * 1000 && /перевод|перечисление|счет|сч[её]т/.test(candidate.description.toLowerCase())); - if (!match) return false; - matchedExternalTransfers.add(match.id); - return true; - }; + const matchedBrokerTransfers = matchExternalCashTransfers(cashResult.rows); for (const row of cashResult.rows.filter((item) => ['brokerage', 'iis'].includes(item.account_type ?? ''))) { const accountId = Number(row.account_id); const account = accounts.get(accountId) ?? { accountId, accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null }; const amount = Number(row.amount_signed); - const kind = classifyPortfolioCash(amount, row.description, hasExternalTransfer(row)); + const kind = classifyPortfolioCash(amount, row.description, matchedBrokerTransfers.has(row.id)); if (kind === 'income') account.income += amount; else if (kind === 'contribution') account.contributions += amount; else if (kind === 'withdrawal') account.withdrawals += -amount; diff --git a/backend/src/services/portfolioOverview.test.ts b/backend/src/services/portfolioOverview.test.ts index 2f01ffa..9023800 100644 --- a/backend/src/services/portfolioOverview.test.ts +++ b/backend/src/services/portfolioOverview.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { calculatePortfolioTradeResults, classifyPortfolioCash, sumKnownPortfolioValuations, toPortfolioHistory, toPortfolioOverview } from './portfolio'; +import { calculatePortfolioTradeResults, classifyPortfolioCash, matchExternalCashTransfers, sumKnownPortfolioValuations, toPortfolioHistory, toPortfolioOverview } from './portfolio'; const result = toPortfolioOverview([ { account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Облигация', isin: 'RU0000000001', quantity: '1', price: '100', valuation: '100' }, @@ -35,4 +35,10 @@ assert.equal(classifyPortfolioCash(52000, 'Пополнение брокерск assert.equal(classifyPortfolioCash(-129000, 'Вывод ДС под нерассчитанные сделки', true), 'withdrawal'); assert.equal(classifyPortfolioCash(-129000, 'Сальдо расчетов по сделкам с ценными бумагами', true), 'withdrawal'); assert.equal(sumKnownPortfolioValuations([100, null, 25]), 125); +const matched = matchExternalCashTransfers([ + { id: 1, account_id: 1, alias: null, bank: 'VTB', account_number: 'broker', account_type: 'brokerage', operation_at: '2026-05-12T00:00:00+03:00', amount_signed: '-5102321', description: 'Вывод ДС под нерассчитанные сделки' }, + { id: 2, account_id: 1, alias: null, bank: 'VTB', account_number: 'broker', account_type: 'brokerage', operation_at: '2026-05-13T00:00:00+03:00', amount_signed: '-4885479', description: 'Вывод ДС под нерассчитанные сделки' }, + { id: 3, account_id: 2, alias: null, bank: 'VTB', account_number: 'current', account_type: 'current', operation_at: '2026-05-12T00:00:00+03:00', amount_signed: '9987800', description: 'Вывод денежных средств с брокерского счета' }, +]); +assert.deepEqual([...matched], [1, 2]); console.log('portfolio cash classification: OK'); diff --git a/package-lock.json b/package-lock.json index 2125541..8f6729f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "backend": { "name": "@family-budget/backend", - "version": "0.15.3", + "version": "0.15.4", "dependencies": { "@family-budget/shared": "*", "cookie-parser": "^1.4.7", From a6250f15929556a984fa2749a7dc624afce86ae8 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 28 Aug 2026 23:54:10 +0300 Subject: [PATCH 3/4] fix: match broker transfers regardless of description --- CHANGELOG.md | 6 ++++++ backend/package.json | 2 +- backend/src/services/portfolio.ts | 2 +- backend/src/services/portfolioOverview.test.ts | 2 +- package-lock.json | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a969798..8af0ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Backend 0.15.5] - 2026-08-28 + +### Fixed + +- Trust a matched family-account transfer even when the broker uses an unexpected operation description. + ## [Backend 0.15.4] - 2026-08-28 ### Fixed diff --git a/backend/package.json b/backend/package.json index e9e9f9a..13797b7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.15.4", + "version": "0.15.5", "private": true, "scripts": { "dev": "tsx watch src/app.ts", diff --git a/backend/src/services/portfolio.ts b/backend/src/services/portfolio.ts index 8281341..d160307 100644 --- a/backend/src/services/portfolio.ts +++ b/backend/src/services/portfolio.ts @@ -58,7 +58,7 @@ export function matchExternalCashTransfers(rows: PerformanceCashRow[]): Set !matchedBroker.has(row.id) && Math.sign(Number(row.amount_signed)) === -Math.sign(targetAmount) && Math.abs(Math.round(Number(row.amount_signed))) <= Math.abs(targetAmount) && Math.abs(Date.parse(row.operation_at) - Date.parse(target.operation_at)) <= 3 * 24 * 60 * 60 * 1000 && /вывод|сальдо расчетов|перевод|перечисление/.test(row.description.toLowerCase())); + const candidates = broker.filter((row) => !matchedBroker.has(row.id) && Math.sign(Number(row.amount_signed)) === -Math.sign(targetAmount) && Math.abs(Math.round(Number(row.amount_signed))) <= Math.abs(targetAmount) && Math.abs(Date.parse(row.operation_at) - Date.parse(target.operation_at)) <= 3 * 24 * 60 * 60 * 1000); const exact = candidates.find((row) => Math.round(Number(row.amount_signed)) === -targetAmount); let group: PerformanceCashRow[] = exact ? [exact] : []; if (group.length === 0) { diff --git a/backend/src/services/portfolioOverview.test.ts b/backend/src/services/portfolioOverview.test.ts index 9023800..c19586c 100644 --- a/backend/src/services/portfolioOverview.test.ts +++ b/backend/src/services/portfolioOverview.test.ts @@ -36,7 +36,7 @@ assert.equal(classifyPortfolioCash(-129000, 'Вывод ДС под нерасс assert.equal(classifyPortfolioCash(-129000, 'Сальдо расчетов по сделкам с ценными бумагами', true), 'withdrawal'); assert.equal(sumKnownPortfolioValuations([100, null, 25]), 125); const matched = matchExternalCashTransfers([ - { id: 1, account_id: 1, alias: null, bank: 'VTB', account_number: 'broker', account_type: 'brokerage', operation_at: '2026-05-12T00:00:00+03:00', amount_signed: '-5102321', description: 'Вывод ДС под нерассчитанные сделки' }, + { id: 1, account_id: 1, alias: null, bank: 'VTB', account_number: 'broker', account_type: 'brokerage', operation_at: '2026-05-12T00:00:00+03:00', amount_signed: '-5102321', description: 'Нестандартное описание операции' }, { id: 2, account_id: 1, alias: null, bank: 'VTB', account_number: 'broker', account_type: 'brokerage', operation_at: '2026-05-13T00:00:00+03:00', amount_signed: '-4885479', description: 'Вывод ДС под нерассчитанные сделки' }, { id: 3, account_id: 2, alias: null, bank: 'VTB', account_number: 'current', account_type: 'current', operation_at: '2026-05-12T00:00:00+03:00', amount_signed: '9987800', description: 'Вывод денежных средств с брокерского счета' }, ]); diff --git a/package-lock.json b/package-lock.json index 8f6729f..9107594 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "backend": { "name": "@family-budget/backend", - "version": "0.15.4", + "version": "0.15.5", "dependencies": { "@family-budget/shared": "*", "cookie-parser": "^1.4.7", From a6a46e0d845fe8c723ce1f7d2d67bbd979660478 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 29 Aug 2026 01:02:32 +0300 Subject: [PATCH 4/4] fix: count confirmed broker withdrawals by account reference --- CHANGELOG.md | 6 +++++ backend/package.json | 2 +- backend/src/services/portfolio.ts | 22 ++++++++++++++++--- .../src/services/portfolioOverview.test.ts | 4 +++- package-lock.json | 2 +- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af0ea8..f8abac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Backend 0.15.6] - 2026-08-29 + +### Fixed + +- Count broker withdrawals from explicit positive transfers to family accounts, linked by the broker account number in the operation description. + ## [Backend 0.15.5] - 2026-08-28 ### Fixed diff --git a/backend/package.json b/backend/package.json index 13797b7..5c90655 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.15.5", + "version": "0.15.6", "private": true, "scripts": { "dev": "tsx watch src/app.ts", diff --git a/backend/src/services/portfolio.ts b/backend/src/services/portfolio.ts index d160307..1737164 100644 --- a/backend/src/services/portfolio.ts +++ b/backend/src/services/portfolio.ts @@ -36,6 +36,10 @@ type PerformancePositionRow = { account_id: number | string; quantity: string | export type PortfolioCashKind = 'income' | 'contribution' | 'withdrawal' | 'other'; +export function extractBrokerWithdrawalAccount(description: string): string | null { + return description.toLowerCase().replace(/ё/g, 'е').match(/вывод денежных средств с брокерского счета\s+([0-9]+)/)?.[1] ?? null; +} + export function classifyPortfolioCash(amount: number, description: string, matchedExternalTransfer = false): PortfolioCashKind { const text = description.toLowerCase().replace(/ё/g, 'е'); if (amount > 0 && /дивиденд|купон|процент/.test(text)) return 'income'; @@ -361,15 +365,27 @@ export async function getPortfolioPerformance(): Promise(); + const brokerRows = cashResult.rows.filter((item) => ['brokerage', 'iis'].includes(item.account_type ?? '')); const matchedBrokerTransfers = matchExternalCashTransfers(cashResult.rows); - for (const row of cashResult.rows.filter((item) => ['brokerage', 'iis'].includes(item.account_type ?? ''))) { + const accountByReference = new Map(brokerRows.map((row) => [Number(row.account_id), row.account_number.replace(/\D/g, '')])); + for (const row of brokerRows) { const accountId = Number(row.account_id); const account = accounts.get(accountId) ?? { accountId, accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null }; const amount = Number(row.amount_signed); - const kind = classifyPortfolioCash(amount, row.description, matchedBrokerTransfers.has(row.id)); + const kind = classifyPortfolioCash(amount, row.description, amount > 0 && matchedBrokerTransfers.has(row.id)); if (kind === 'income') account.income += amount; else if (kind === 'contribution') account.contributions += amount; - else if (kind === 'withdrawal') account.withdrawals += -amount; + accounts.set(accountId, account); + } + for (const row of cashResult.rows.filter((item) => !['brokerage', 'iis'].includes(item.account_type ?? '') && Number(item.amount_signed) > 0)) { + const reference = extractBrokerWithdrawalAccount(row.description); + if (!reference) continue; + const accountEntry = [...accountByReference.entries()].find(([, accountNumber]) => accountNumber.endsWith(reference) || reference.endsWith(accountNumber)); + if (!accountEntry) continue; + const accountId = accountEntry[0]; + const source = brokerRows.find((item) => Number(item.account_id) === accountId); + const account = accounts.get(accountId) ?? { accountId, accountName: source?.alias || `${source?.bank ?? 'Брокерский счёт'} · ${maskAccountNumber(source?.account_number ?? reference)}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null }; + account.withdrawals += Number(row.amount_signed); accounts.set(accountId, account); } for (const [accountId, values] of tradeMap) { diff --git a/backend/src/services/portfolioOverview.test.ts b/backend/src/services/portfolioOverview.test.ts index c19586c..6ae8f4e 100644 --- a/backend/src/services/portfolioOverview.test.ts +++ b/backend/src/services/portfolioOverview.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { calculatePortfolioTradeResults, classifyPortfolioCash, matchExternalCashTransfers, sumKnownPortfolioValuations, toPortfolioHistory, toPortfolioOverview } from './portfolio'; +import { calculatePortfolioTradeResults, classifyPortfolioCash, extractBrokerWithdrawalAccount, matchExternalCashTransfers, sumKnownPortfolioValuations, toPortfolioHistory, toPortfolioOverview } from './portfolio'; const result = toPortfolioOverview([ { account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Облигация', isin: 'RU0000000001', quantity: '1', price: '100', valuation: '100' }, @@ -34,6 +34,8 @@ assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных assert.equal(classifyPortfolioCash(52000, 'Пополнение брокерского счёта'), 'contribution'); assert.equal(classifyPortfolioCash(-129000, 'Вывод ДС под нерассчитанные сделки', true), 'withdrawal'); assert.equal(classifyPortfolioCash(-129000, 'Сальдо расчетов по сделкам с ценными бумагами', true), 'withdrawal'); +assert.equal(extractBrokerWithdrawalAccount('Перевод между своими счетами. Вывод денежных средств с брокерского счета 30601 по распоряжению от 2026-05-12.'), '30601'); +assert.equal(extractBrokerWithdrawalAccount('Вывод денежных средств с брокерского счёта 30601'), '30601'); assert.equal(sumKnownPortfolioValuations([100, null, 25]), 125); const matched = matchExternalCashTransfers([ { id: 1, account_id: 1, alias: null, bank: 'VTB', account_number: 'broker', account_type: 'brokerage', operation_at: '2026-05-12T00:00:00+03:00', amount_signed: '-5102321', description: 'Нестандартное описание операции' }, diff --git a/package-lock.json b/package-lock.json index 9107594..f60bc7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "backend": { "name": "@family-budget/backend", - "version": "0.15.5", + "version": "0.15.6", "dependencies": { "@family-budget/shared": "*", "cookie-parser": "^1.4.7",