feat: add manual bank card ledger

Store exact RUB balances, atomic transfers, linked refunds and versioned edits independently of T-Bank. Add migration, HTTP and durability verification.
This commit is contained in:
2026-09-12 18:34:18 +03:00
parent 8ec172f48e
commit e6f58ef0ee
36 changed files with 2436 additions and 99 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
import {
StockSearchQueryDto,
StockTickerParamsDto,
+2 -2
View File
@@ -12,14 +12,14 @@ import {
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import {
decimal,
decimalFromString,
divide,
formatDecimal,
multiply,
subtract,
add,
} from './decimal';
} from '../common/decimal';
import { decimal } from './decimal';
import { NcrRatingsClient } from './ncr-ratings.client';
import { MoexClient } from './moex.client';
import { PortfolioService } from './portfolio.service';
+1 -1
View File
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true })
+2 -1
View File
@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { ServiceUnavailableException } from '@nestjs/common';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { decimal, isZero } from './decimal';
import { isZero } from '../common/decimal';
import { decimal } from './decimal';
import { totalsByCurrency } from './portfolio-totals';
import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client';
+2 -1
View File
@@ -12,7 +12,8 @@ import {
MaxLength,
Min,
} from 'class-validator';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
import { AccountReferenceDto } from './account-reference.dto';
export const operationCategories = [
+2 -1
View File
@@ -1,4 +1,5 @@
import { decimal, formatDecimal } from './decimal';
import { formatDecimal } from '../common/decimal';
import { decimal } from './decimal';
import {
BrokerageOperationDto,
OperationCategory,
+2 -1
View File
@@ -1,4 +1,5 @@
import { add, decimal, formatDecimal, multiply, subtract } from './decimal';
import { add, formatDecimal, multiply, subtract } from '../common/decimal';
import { decimal } from './decimal';
describe('decimal', () => {
it('keeps signed nanos exact without floats', () => {
+2 -62
View File
@@ -1,18 +1,8 @@
import { Decimal } from '../common/decimal';
const NANO = 1_000_000_000n;
export type ProtoDecimal = { units?: string | number; nano?: string | number };
export type Decimal = { value: bigint; scale: number };
export function decimalFromString(value: string): Decimal {
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
throw new Error('Invalid decimal string');
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}
export function decimal(value: ProtoDecimal): Decimal {
return {
@@ -20,53 +10,3 @@ export function decimal(value: ProtoDecimal): Decimal {
scale: 9,
};
}
export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale);
return {
value:
left.value * 10n ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale),
scale,
};
}
export function subtract(left: Decimal, right: Decimal): Decimal {
return add(left, { value: -right.value, scale: right.scale });
}
export function multiply(left: Decimal, right: Decimal): Decimal {
return { value: left.value * right.value, scale: left.scale + right.scale };
}
export function isZero(value: Decimal): boolean {
return value.value === 0n;
}
export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value;
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
}
export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0';
const sign = value < 0n ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
const digits = (value < 0n ? -value : value)
.toString()
.padStart(scale + 1, '0');
const whole = digits.slice(0, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, '');
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
}
// Round half away from zero; callers choose presentation precision explicitly.
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
const n = numerator < 0n ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale };
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator';
import { BrokerageOperationDto } from './brokerage-operation.dto';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
+2 -1
View File
@@ -7,7 +7,8 @@ import {
decimalFromString,
formatDecimal,
subtract,
} from './decimal';
} from '../common/decimal';
import {
DateOnlyRange,
IncomeBucketGranularity,
+4 -2
View File
@@ -4,8 +4,10 @@ import {
divide,
formatDecimal,
multiply,
} from './decimal';
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
} from '../common/decimal';
import { MoneyDto } from '../common/money.dto';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { StockPositionDto } from './stock.dto';
export function instrumentPosition(
+3 -2
View File
@@ -1,6 +1,7 @@
import { add, decimalFromString, formatDecimal } from './decimal';
import { add, decimalFromString, formatDecimal } from '../common/decimal';
import { MoneyDto } from '../common/money.dto';
import {
MoneyDto,
PortfolioPositionDto,
PortfolioStructureDto,
PortfolioStructureCategoryDto,
+4 -3
View File
@@ -1,12 +1,13 @@
import {
add,
decimal,
decimalFromString,
formatDecimal,
isZero,
multiply,
} from './decimal';
import { MoneyDto } from './portfolio.dto';
} from '../common/decimal';
import { decimal } from './decimal';
import { MoneyDto } from '../common/money.dto';
import { TbankPortfolio, TbankPositions } from './tbank.client';
function totalsByCurrency(
+1 -8
View File
@@ -1,14 +1,7 @@
import { MoneyDto } from '../common/money.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AccountReferenceDto } from './account-reference.dto';
export class MoneyDto {
@ApiProperty()
currency: string;
@ApiProperty()
value: string;
}
export class PortfolioPositionDto {
@ApiPropertyOptional({ type: String, nullable: true })
instrumentUid?: string | null;
+4 -3
View File
@@ -2,15 +2,16 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { RequestContextService } from '../common/logging/request-context.service';
import {
add,
decimal,
formatDecimal,
isZero,
multiply,
subtract,
} from './decimal';
} from '../common/decimal';
import { decimal } from './decimal';
import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals';
import { portfolioStructure } from './portfolio-structure';
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import {
TbankAccessService,
TbankConnectionContext,
+2 -1
View File
@@ -8,7 +8,8 @@ import {
Matches,
MinLength,
} from 'class-validator';
import { MoneyDto, PortfolioPositionDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
import { PortfolioPositionDto } from './portfolio.dto';
export class StockSearchQueryDto {
@ApiProperty({ minLength: 2, maxLength: 80 })
+2 -2
View File
@@ -13,13 +13,13 @@ import {
} from '@nestjs/common';
import { LRUCache } from 'lru-cache';
import {
decimal,
decimalFromString,
divide,
formatDecimal,
multiply,
subtract,
} from './decimal';
} from '../common/decimal';
import { decimal } from './decimal';
import { PortfolioService } from './portfolio.service';
import { stockPosition } from './stock-position';
import { TbankAccessService } from './tbank-access.service';
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, Matches } from 'class-validator';
import { AccountReferenceDto } from './account-reference.dto';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
export class UpcomingEventsQueryDto {
@ApiPropertyOptional({
+4 -3
View File
@@ -3,14 +3,15 @@ import { RequestContextService } from '../common/logging/request-context.service
import { StructuredLogger } from '../common/logging/structured-logger.service';
import {
add,
decimal,
decimalFromString,
formatDecimal,
isZero,
multiply,
} from './decimal';
} from '../common/decimal';
import { decimal } from './decimal';
import { MoexBondEvent, MoexClient } from './moex.client';
import { MoneyDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
import { TbankAccessService } from './tbank-access.service';
import {
TbankClient,