Merge pull request 'Учитывать подтверждённый вывод с брокерского счёта' (#52) from fix/confirmed-broker-withdrawals into main
Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- Trust a matched family-account transfer even when the broker uses an unexpected operation description.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- Recognize confirmed broker withdrawals even when the broker description does not contain the word «вывод».
|
||||||
|
|
||||||
## [Backend 0.15.2] - 2026-08-28
|
## [Backend 0.15.2] - 2026-08-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.15.2",
|
"version": "0.15.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
|
|||||||
@@ -30,17 +30,22 @@ type PortfolioHistoryRow = {
|
|||||||
total_valuation: string | number | null;
|
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 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 };
|
type PerformancePositionRow = { account_id: number | string; quantity: string | number; valuation: string | number | null; isin: string | null; instrument: string };
|
||||||
|
|
||||||
export type PortfolioCashKind = 'income' | 'contribution' | 'withdrawal' | 'other';
|
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 {
|
export function classifyPortfolioCash(amount: number, description: string, matchedExternalTransfer = false): PortfolioCashKind {
|
||||||
const text = description.toLowerCase().replace(/ё/g, 'е');
|
const text = description.toLowerCase().replace(/ё/g, 'е');
|
||||||
if (amount > 0 && /дивиденд|купон|процент/.test(text)) return 'income';
|
if (amount > 0 && /дивиденд|купон|процент/.test(text)) return 'income';
|
||||||
if (amount > 0 && (matchedExternalTransfer || /пополнение/.test(text)) && !text.includes('под нерассчитанные сделки')) return 'contribution';
|
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';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +54,31 @@ export function sumKnownPortfolioValuations(values: Array<number | null>): numbe
|
|||||||
return known.length > 0 ? known.reduce((sum, value) => sum + value, 0) : null;
|
return known.length > 0 ? known.reduce((sum, value) => sum + value, 0) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function matchExternalCashTransfers(rows: PerformanceCashRow[]): Set<number | string> {
|
||||||
|
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<number | string>();
|
||||||
|
const matchedExternal = new Set<number | string>();
|
||||||
|
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);
|
||||||
|
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 {
|
function decimalValue(value: unknown, field: string, required = false): string | null {
|
||||||
if (value == null || value === '') {
|
if (value == null || value === '') {
|
||||||
if (required) throw new Error(`${field} is required`);
|
if (required) throw new Error(`${field} is required`);
|
||||||
@@ -335,24 +365,27 @@ export async function getPortfolioPerformance(): Promise<PortfolioPerformanceRes
|
|||||||
]);
|
]);
|
||||||
const tradeMap = calculatePortfolioTradeResults(tradeResult.rows, positionResult.rows);
|
const tradeMap = calculatePortfolioTradeResults(tradeResult.rows, positionResult.rows);
|
||||||
const accounts = new Map<number, PortfolioPerformanceResponse['accounts'][number]>();
|
const accounts = new Map<number, PortfolioPerformanceResponse['accounts'][number]>();
|
||||||
const matchedExternalTransfers = new Set<number | string>();
|
const brokerRows = cashResult.rows.filter((item) => ['brokerage', 'iis'].includes(item.account_type ?? ''));
|
||||||
const externalCash = cashResult.rows.filter((row) => !['brokerage', 'iis'].includes(row.account_type ?? ''));
|
const matchedBrokerTransfers = matchExternalCashTransfers(cashResult.rows);
|
||||||
const hasExternalTransfer = (row: PerformanceCashRow): boolean => {
|
const accountByReference = new Map(brokerRows.map((row) => [Number(row.account_id), row.account_number.replace(/\D/g, '')]));
|
||||||
const target = -Number(row.amount_signed);
|
for (const row of brokerRows) {
|
||||||
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;
|
|
||||||
};
|
|
||||||
for (const row of cashResult.rows.filter((item) => ['brokerage', 'iis'].includes(item.account_type ?? ''))) {
|
|
||||||
const accountId = Number(row.account_id);
|
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 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 amount = Number(row.amount_signed);
|
||||||
const kind = classifyPortfolioCash(amount, row.description, hasExternalTransfer(row));
|
const kind = classifyPortfolioCash(amount, row.description, amount > 0 && matchedBrokerTransfers.has(row.id));
|
||||||
if (kind === 'income') account.income += amount;
|
if (kind === 'income') account.income += amount;
|
||||||
else if (kind === 'contribution') account.contributions += 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);
|
accounts.set(accountId, account);
|
||||||
}
|
}
|
||||||
for (const [accountId, values] of tradeMap) {
|
for (const [accountId, values] of tradeMap) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { calculatePortfolioTradeResults, classifyPortfolioCash, sumKnownPortfolioValuations, toPortfolioHistory, toPortfolioOverview } from './portfolio';
|
import { calculatePortfolioTradeResults, classifyPortfolioCash, extractBrokerWithdrawalAccount, matchExternalCashTransfers, sumKnownPortfolioValuations, toPortfolioHistory, toPortfolioOverview } from './portfolio';
|
||||||
|
|
||||||
const result = toPortfolioOverview([
|
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' },
|
{ 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' },
|
||||||
@@ -33,5 +33,14 @@ assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных
|
|||||||
assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных средств для приобретения ценных бумаг', true), 'contribution');
|
assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных средств для приобретения ценных бумаг', true), 'contribution');
|
||||||
assert.equal(classifyPortfolioCash(52000, 'Пополнение брокерского счёта'), 'contribution');
|
assert.equal(classifyPortfolioCash(52000, 'Пополнение брокерского счёта'), 'contribution');
|
||||||
assert.equal(classifyPortfolioCash(-129000, 'Вывод ДС под нерассчитанные сделки', true), 'withdrawal');
|
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);
|
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');
|
console.log('portfolio cash classification: OK');
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.15.2",
|
"version": "0.15.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@family-budget/shared": "*",
|
"@family-budget/shared": "*",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
|||||||
Reference in New Issue
Block a user