refactor!: support RUB only (WealthLedger/main#55) (#204)
BREAKING CHANGE: per-currency API fields replaced with single RUB Money.
This commit was merged in pull request #204.
This commit is contained in:
@@ -91,7 +91,7 @@ function seedTbank(owner) {
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now, owner);
|
||||
insert('TbankAccount', { ...scope, type: 'BROKERAGE', name: 'Synthetic persisted account', upstreamOrder: 0 });
|
||||
insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cashBalances: [], totalsByCurrency: [], structure: { currencies: [], unvaluedCategories: [] } }), fetchedAt: now });
|
||||
insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cash: { currency: 'RUB', value: '0' }, totals: { currency: 'RUB', value: '0' }, structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] } }), fetchedAt: now });
|
||||
insert('TbankOperation', { ...scope, brokerOperationId: 'durable-income', occurredAt: now, category: 'income', payload: JSON.stringify({ id: 'durable-income', payment: exact }) });
|
||||
insert('TbankOperationCoverage', { ...scope, from: now - 1000, to: now + 1000, fetchedAt: now, asOf: now });
|
||||
insert('TbankForecast', { userId: owner, revision, scopeKey: 'durable-forecast', membershipRevision: 1, payload: JSON.stringify({ incomeTotalsByCurrency: [exact] }), dependencyRevisions: JSON.stringify({ portfolio: 1 }), sourceTimestamps: JSON.stringify({ portfolio: new Date(now).toISOString() }), calculatedAt: now });
|
||||
|
||||
@@ -62,12 +62,12 @@ export function failUpstream(rpc: jest.Mock, kind: 'bond' | 'share', code: numbe
|
||||
uid: 'e6123145-9665-43e0-8413-cd61b8aa9b13',
|
||||
};
|
||||
const healthy = new Map<string, unknown>([
|
||||
['bondBy', { instrument }],
|
||||
['bondBy', { instrument: { ...instrument, currency: 'rub' } }],
|
||||
['findInstrument', { instruments: [instrument] }],
|
||||
['getAssetBy', { asset: { description: 'description' } }],
|
||||
['getAssetFundamentals', { fundamentals: [] }],
|
||||
['getLastPrices', { lastPrices: [] }],
|
||||
['shareBy', { instrument }],
|
||||
['shareBy', { instrument: { ...instrument, currency: 'rub' } }],
|
||||
]);
|
||||
const failure = Object.assign(new Error('grpc failure'), {
|
||||
code,
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('OpenAPI contract (integration)', () => {
|
||||
expect.arrayContaining([
|
||||
'accountId',
|
||||
'period',
|
||||
'totalsByCurrency',
|
||||
'total',
|
||||
'calculatedAt',
|
||||
'bucketGranularity',
|
||||
'buckets',
|
||||
|
||||
@@ -111,25 +111,20 @@ describe('Portfolio structure HTTP contract', () => {
|
||||
expect((response.body as { data: unknown }).data).toMatchObject({
|
||||
structure: {
|
||||
unvaluedCategories: [],
|
||||
currencies: [
|
||||
totalValue: id === 'all' ? '200' : '100',
|
||||
hasNegativeValues: false,
|
||||
categories: [
|
||||
{
|
||||
currency: 'RUB',
|
||||
totalValue: id === 'all' ? '200' : '100',
|
||||
hasNegativeValues: false,
|
||||
categories: [
|
||||
{
|
||||
type: 'STOCK',
|
||||
value: id === 'all' ? '120' : '60',
|
||||
sharePercent: '60',
|
||||
},
|
||||
{ type: 'BOND', value: '0', sharePercent: '0' },
|
||||
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
||||
{
|
||||
type: 'CASH',
|
||||
value: id === 'all' ? '80' : '40',
|
||||
sharePercent: '40',
|
||||
},
|
||||
],
|
||||
type: 'STOCK',
|
||||
value: id === 'all' ? '120' : '60',
|
||||
sharePercent: '60',
|
||||
},
|
||||
{ type: 'BOND', value: '0', sharePercent: '0' },
|
||||
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
||||
{
|
||||
type: 'CASH',
|
||||
value: id === 'all' ? '80' : '40',
|
||||
sharePercent: '40',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -149,15 +144,11 @@ describe('Portfolio structure HTTP contract', () => {
|
||||
expect((response.body as { data: unknown }).data).toMatchObject({
|
||||
structure: {
|
||||
unvaluedCategories: [{ type: 'BOND', count: 1 }],
|
||||
currencies: [
|
||||
{
|
||||
categories: [
|
||||
{ sharePercent: null },
|
||||
{ sharePercent: null },
|
||||
{ sharePercent: null },
|
||||
{ value: '40', sharePercent: null },
|
||||
],
|
||||
},
|
||||
categories: [
|
||||
{ sharePercent: null },
|
||||
{ sharePercent: null },
|
||||
{ sharePercent: null },
|
||||
{ value: '40', sharePercent: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -174,17 +165,13 @@ describe('Portfolio structure HTTP contract', () => {
|
||||
.expect(200);
|
||||
expect((response.body as { data: unknown }).data).toMatchObject({
|
||||
structure: {
|
||||
currencies: [
|
||||
{
|
||||
hasNegativeValues: true,
|
||||
totalValue: '150',
|
||||
categories: [
|
||||
{ value: '120', sharePercent: null },
|
||||
{ sharePercent: null },
|
||||
{ sharePercent: null },
|
||||
{ value: '30', sharePercent: null },
|
||||
],
|
||||
},
|
||||
hasNegativeValues: true,
|
||||
totalValue: '150',
|
||||
categories: [
|
||||
{ value: '120', sharePercent: null },
|
||||
{ sharePercent: null },
|
||||
{ sharePercent: null },
|
||||
{ value: '30', sharePercent: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,7 +103,6 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
for (const [id, units, nano, currency, type] of [
|
||||
['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'],
|
||||
['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'],
|
||||
['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'],
|
||||
] as const) {
|
||||
await prisma.tbankOperation.create({
|
||||
data: {
|
||||
@@ -130,11 +129,8 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
assert(result.data);
|
||||
expect(result.meta.availability).toBe('available');
|
||||
expect(result.meta.completeness).toBe('complete');
|
||||
expect(result.data.totalsByCurrency.map((row) => row.total)).toEqual([
|
||||
{ currency: 'RUB', value: '15.000000002' },
|
||||
{ currency: 'USD', value: '6' },
|
||||
]);
|
||||
expect(result.data.entries).toHaveLength(6);
|
||||
expect(result.data.total.total).toEqual({ currency: 'RUB', value: '15.000000002' });
|
||||
expect(result.data.entries).toHaveLength(4);
|
||||
expect(
|
||||
result.data.entries
|
||||
.filter((row) => row.id === 'same')
|
||||
@@ -185,7 +181,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
assert(after.data);
|
||||
expect(after.data.entries.find((entry) => entry.account?.id === 'a' && entry.id === 'same')?.logoName).toBe('bond.png');
|
||||
expect(after.data.entries.find((entry) => entry.account?.id === 'b' && entry.id === 'same')?.logoName).toBeNull();
|
||||
expect(after.data.totalsByCurrency).toEqual(base.totalsByCurrency);
|
||||
expect(after.data.total).toEqual(base.total);
|
||||
expect(after.data.calculatedAt).toBe(base.calculatedAt);
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -205,8 +201,8 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
new Date('2025-03-31T21:00:00Z'),
|
||||
);
|
||||
expect(
|
||||
(await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency,
|
||||
).toEqual([]);
|
||||
(await read('a', 'from=2025-03-01&to=2025-03-31')).data?.total.total,
|
||||
).toEqual({ currency: 'RUB', value: '0' });
|
||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledWith(
|
||||
'synthetic', expect.objectContaining({ accountId: 'b' }),
|
||||
);
|
||||
@@ -225,11 +221,8 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
});
|
||||
const result = await read();
|
||||
assert(result.data);
|
||||
expect(result.data.entries).toHaveLength(6);
|
||||
expect(
|
||||
result.data.totalsByCurrency.find(({ currency }) => currency === 'RUB')
|
||||
?.total,
|
||||
).toEqual({ currency: 'RUB', value: '25.000000002' });
|
||||
expect(result.data.entries).toHaveLength(4);
|
||||
expect(result.data.total.total).toEqual({ currency: 'RUB', value: '25.000000002' });
|
||||
});
|
||||
it('preserves the complete financial result and timestamp through failed atomic reconciliation', async () => {
|
||||
const before = await read();
|
||||
|
||||
@@ -268,7 +268,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
expect(
|
||||
income.data?.entries.map(({ id, occurredAt }) => ({ id, occurredAt })),
|
||||
).toEqual(expected);
|
||||
expect(income.data?.totalsByCurrency[0].total).toEqual({
|
||||
expect(income.data?.total.total).toEqual({
|
||||
currency: 'RUB',
|
||||
value: '2.000000002',
|
||||
});
|
||||
|
||||
@@ -218,32 +218,27 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
bondPositionCount: 0,
|
||||
fundPositionCount: 0,
|
||||
totalPositionCount: 1,
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '21.100000002' }],
|
||||
cashBalances: [{ currency: 'RUB', value: '1.1' }],
|
||||
totals: { currency: 'RUB', value: '21.100000002' },
|
||||
cash: { currency: 'RUB', value: '1.1' },
|
||||
structure: {
|
||||
currencies: [{
|
||||
currency: 'RUB',
|
||||
totalValue: '21.100000002',
|
||||
hasNegativeValues: false,
|
||||
categories: [
|
||||
{ type: 'STOCK', value: '20.000000002', sharePercent: '94.78673' },
|
||||
{ type: 'BOND', value: '0', sharePercent: '0' },
|
||||
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
||||
{ type: 'CASH', value: '1.1', sharePercent: '5.21327' },
|
||||
],
|
||||
}],
|
||||
totalValue: '21.100000002',
|
||||
hasNegativeValues: false,
|
||||
categories: [
|
||||
{ type: 'STOCK', value: '20.000000002', sharePercent: '94.78673' },
|
||||
{ type: 'BOND', value: '0', sharePercent: '0' },
|
||||
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
||||
{ type: 'CASH', value: '1.1', sharePercent: '5.21327' },
|
||||
],
|
||||
unvaluedCategories: [],
|
||||
},
|
||||
unrealizedReturnByCurrency: [
|
||||
{ currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||
],
|
||||
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||
});
|
||||
const portfolio = await get('brokerage-accounts/account/portfolio').expect(
|
||||
200,
|
||||
);
|
||||
expect(
|
||||
(portfolio.body as PortfolioResourceDto).data?.totalsByCurrency,
|
||||
).toEqual([{ currency: 'RUB', value: '21.100000002' }]);
|
||||
(portfolio.body as PortfolioResourceDto).data?.totals,
|
||||
).toEqual({ currency: 'RUB', value: '21.100000002' });
|
||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
@@ -315,7 +310,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
logoName: 'enriched.png',
|
||||
quantity: '2',
|
||||
});
|
||||
expect(after.data?.totalsByCurrency).toEqual(before.data?.totalsByCurrency);
|
||||
expect(after.data?.totals).toEqual(before.data?.totals);
|
||||
expect(after.data?.lastSuccessfulAt).toBe(before.data?.lastSuccessfulAt);
|
||||
expect(after.meta.dataRevision).not.toBe(before.meta.dataRevision);
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
@@ -513,12 +508,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
const summaries = (await get('brokerage-accounts').expect(200))
|
||||
.body as BrokerageAccountsResourceDto;
|
||||
expect(summaries.data?.[0].portfolioSummary).toMatchObject({
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '21.1' }],
|
||||
cashBalances: [{ currency: 'RUB', value: '1.1' }],
|
||||
structure: { currencies: [{ currency: 'RUB' }] },
|
||||
unrealizedReturnByCurrency: [
|
||||
{ currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||
],
|
||||
totals: { currency: 'RUB', value: '21.1' },
|
||||
cash: { currency: 'RUB', value: '1.1' },
|
||||
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||
stockPositionCount: 1,
|
||||
bondPositionCount: 0,
|
||||
fundPositionCount: 0,
|
||||
|
||||
@@ -16,18 +16,15 @@ describe('portfolio publication (SQLite)', () => {
|
||||
const snapshot = {
|
||||
lastSuccessfulAt: '2026-01-01T00:00:00.000Z',
|
||||
isStale: false,
|
||||
totalsByCurrency: [],
|
||||
totals: { currency: 'RUB', value: '0' },
|
||||
positions: [],
|
||||
cashBalances: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cash: { currency: 'RUB', value: '0' },
|
||||
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||
} satisfies PortfolioDto;
|
||||
const accountId = 'account';
|
||||
const valuedSnapshot = {
|
||||
...snapshot,
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '200' },
|
||||
{ currency: 'USD', value: '10' },
|
||||
],
|
||||
totals: { currency: 'RUB', value: '200' },
|
||||
} satisfies PortfolioDto;
|
||||
const createRunningAttempt = () =>
|
||||
prisma.tbankSyncWork.create({
|
||||
@@ -95,7 +92,7 @@ describe('portfolio publication (SQLite)', () => {
|
||||
expect(await prisma.tbankPortfolio.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('rolls back the snapshot, first value point and claim when the second point fails', async () => {
|
||||
it('rolls back the snapshot, value point and claim when the point write fails', async () => {
|
||||
const previousFetchedAt = new Date('2025-12-31T00:00:00.000Z');
|
||||
const previousCapturedAt = new Date('2025-12-31T01:00:00.000Z');
|
||||
await prisma.tbankPortfolio.create({
|
||||
@@ -114,7 +111,6 @@ describe('portfolio publication (SQLite)', () => {
|
||||
points: {
|
||||
create: {
|
||||
attemptId: 'previous-attempt',
|
||||
currency: 'RUB',
|
||||
value: '100',
|
||||
capturedAt: previousCapturedAt,
|
||||
},
|
||||
@@ -123,11 +119,9 @@ describe('portfolio publication (SQLite)', () => {
|
||||
});
|
||||
await createRunningAttempt();
|
||||
interceptTransaction((tx) => {
|
||||
const upsert = tx.tbankPortfolioValuePoint.upsert.bind(tx.tbankPortfolioValuePoint);
|
||||
jest
|
||||
.spyOn(tx.tbankPortfolioValuePoint, 'upsert')
|
||||
.mockImplementationOnce((args) => upsert(args))
|
||||
.mockRejectedValueOnce(new Error('injected second point failure'));
|
||||
.mockRejectedValueOnce(new Error('injected point failure'));
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -136,7 +130,7 @@ describe('portfolio publication (SQLite)', () => {
|
||||
accountId,
|
||||
valuedSnapshot,
|
||||
),
|
||||
).rejects.toThrow('injected second point failure');
|
||||
).rejects.toThrow('injected point failure');
|
||||
|
||||
expect(
|
||||
await prisma.tbankPortfolio.findUnique({
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('T-Bank persisted read budget', () => {
|
||||
(item) =>
|
||||
item.instrumentKind === 'INSTRUMENT_TYPE_SHARE' &&
|
||||
item.ticker.includes(query),
|
||||
),
|
||||
).map((item) => ({ uid: item.uid, ticker: item.ticker, name: item.name, isin: item.isin, instrumentKind: item.instrumentKind, classCode: item.classCode, apiTradeAvailableFlag: item.apiTradeAvailableFlag })),
|
||||
),
|
||||
findBonds: (_token: string, query: string) =>
|
||||
counted(
|
||||
@@ -123,7 +123,7 @@ describe('T-Bank persisted read budget', () => {
|
||||
(item) =>
|
||||
item.instrumentKind === 'INSTRUMENT_TYPE_BOND' &&
|
||||
item.ticker.includes(query),
|
||||
),
|
||||
).map((item) => ({ uid: item.uid, ticker: item.ticker, name: item.name, isin: item.isin, instrumentKind: item.instrumentKind, classCode: item.classCode, apiTradeAvailableFlag: item.apiTradeAvailableFlag })),
|
||||
),
|
||||
getShare: (_token: string, uid: string) =>
|
||||
counted(
|
||||
@@ -279,9 +279,9 @@ describe('T-Bank persisted read budget', () => {
|
||||
currentPrice: { currency: 'rub', units: '1000' },
|
||||
averagePositionPrice: { currency: 'rub', units: '900' },
|
||||
}],
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '1100' }],
|
||||
cashBalances: [{ currency: 'RUB', value: '100' }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totals: { currency: 'RUB', value: '1100' },
|
||||
cash: { currency: 'RUB', value: '100' },
|
||||
structure: { totalValue: '1100', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||
},
|
||||
fetchedAt: now,
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
],
|
||||
});
|
||||
upstream.getPositions.mockResolvedValue({
|
||||
money: [{ units: '5', currency: 'usd' }],
|
||||
money: [{ units: '5', currency: 'rub' }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
}
|
||||
|
||||
describe('1.1 Point persistence', () => {
|
||||
it('writes points for fully valued snapshot with two currencies', async () => {
|
||||
it('writes a single RUB point for a fully valued snapshot', async () => {
|
||||
await createUser('owner');
|
||||
await putConnection('owner', 'token-a').expect(200);
|
||||
await publishPortfolio('owner', 'a');
|
||||
@@ -133,12 +133,10 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
include: { points: true },
|
||||
});
|
||||
expect(history?.deletedAt).toBeNull();
|
||||
expect(history?.points).toHaveLength(2);
|
||||
expect(history?.points).toHaveLength(1);
|
||||
|
||||
const rubPoint = history?.points.find((p) => p.currency === 'RUB');
|
||||
const usdPoint = history?.points.find((p) => p.currency === 'USD');
|
||||
expect(rubPoint?.value).toBe('21');
|
||||
expect(usdPoint?.value).toBe('5');
|
||||
const rubPoint = history?.points[0];
|
||||
expect(rubPoint?.value).toBe('26');
|
||||
|
||||
const snapshot = await prisma.tbankPortfolio.findUniqueOrThrow({
|
||||
where: {
|
||||
@@ -201,7 +199,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
expect(points).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('writes no points for empty account without currency', async () => {
|
||||
it('writes a zero point for an empty fully valued account', async () => {
|
||||
upstream.getPortfolio.mockResolvedValue({ positions: [] });
|
||||
upstream.getPositions.mockResolvedValue({ money: [] });
|
||||
|
||||
@@ -212,7 +210,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(0);
|
||||
expect(points).toHaveLength(1);
|
||||
expect(points[0].value).toBe('0');
|
||||
});
|
||||
|
||||
it('deduplicates repeated publication of same attempt', async () => {
|
||||
@@ -258,7 +257,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(2);
|
||||
expect(points).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('late attempt after token replacement adds no points', async () => {
|
||||
@@ -282,10 +281,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const published = {
|
||||
lastSuccessfulAt: new Date().toISOString(),
|
||||
isStale: false,
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '100' },
|
||||
{ currency: 'USD', value: '50' },
|
||||
],
|
||||
totals: { currency: 'RUB', value: '100' },
|
||||
positions: [
|
||||
{
|
||||
figi: 'stock',
|
||||
@@ -302,8 +298,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
issuer: null,
|
||||
},
|
||||
],
|
||||
cashBalances: [{ currency: 'USD', value: '50' }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cash: { currency: 'RUB', value: '0' },
|
||||
structure: { totalValue: '100', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||
};
|
||||
|
||||
const result = await app.get(PortfolioService)['repository'].publish(oldWork, 'a', published);
|
||||
@@ -323,7 +319,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const pointsBefore = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(pointsBefore).toHaveLength(2);
|
||||
expect(pointsBefore).toHaveLength(1);
|
||||
|
||||
upstream.getPortfolio.mockRejectedValue(new Error('Upstream failed'));
|
||||
await publishPortfolio('owner', 'a');
|
||||
@@ -331,7 +327,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(pointsAfter).toHaveLength(2);
|
||||
expect(pointsAfter).toHaveLength(1);
|
||||
expect(pointsAfter).toEqual(pointsBefore);
|
||||
});
|
||||
});
|
||||
@@ -345,7 +341,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const oldPoints = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(oldPoints).toHaveLength(2);
|
||||
expect(oldPoints).toHaveLength(1);
|
||||
|
||||
upstream.validateToken.mockResolvedValue([accounts[0]]);
|
||||
await putConnection('owner', 'token-b').expect(200);
|
||||
@@ -361,8 +357,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
expect(newPoints.length).toBeGreaterThan(2);
|
||||
expect(newPoints.slice(0, 2)).toEqual(oldPoints);
|
||||
expect(newPoints.length).toBeGreaterThan(1);
|
||||
expect(newPoints.slice(0, 1)).toEqual(oldPoints);
|
||||
});
|
||||
|
||||
it('hides history when account disappears from membership', async () => {
|
||||
@@ -392,7 +388,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const pointsB = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'b' },
|
||||
});
|
||||
expect(pointsB).toHaveLength(2);
|
||||
expect(pointsB).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves visibility unchanged on membership fetch error', async () => {
|
||||
@@ -424,7 +420,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(2);
|
||||
expect(points).toHaveLength(1);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.delete('/api/v1/tbank-connection')
|
||||
@@ -450,7 +446,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(pointsAfter.length).toBeGreaterThan(2);
|
||||
expect(pointsAfter.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('unhides when disappeared account returns in membership', async () => {
|
||||
@@ -530,7 +526,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||
where: { userId: 'owner', accountId: 'a' },
|
||||
});
|
||||
expect(points).toHaveLength(2);
|
||||
expect(points).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,9 +113,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
lastSuccessfulAt: new Date().toISOString(),
|
||||
isStale: false,
|
||||
positions,
|
||||
totalsByCurrency: [],
|
||||
cashBalances: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totals: { currency: 'RUB', value: '0' },
|
||||
cash: { currency: 'RUB', value: '0' },
|
||||
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||
};
|
||||
await prisma.tbankPortfolio.upsert({
|
||||
where: {
|
||||
@@ -273,17 +273,14 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
single.data?.events.find((event) => event.type === 'offer')
|
||||
?.projectedAmount,
|
||||
).toBeNull();
|
||||
expect(single.data?.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '6' },
|
||||
{ currency: 'USD', value: '20.000000002' },
|
||||
]);
|
||||
expect(single.data?.incomeTotal).toEqual({ currency: 'RUB', value: '6' });
|
||||
const all = (await events('all').expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(
|
||||
all.data?.events.every((event) => event.account?.id === 'account'),
|
||||
).toBe(true);
|
||||
expect(all.data?.incomeTotalsByCurrency).toEqual(
|
||||
single.data?.incomeTotalsByCurrency,
|
||||
expect(all.data?.incomeTotal).toEqual(
|
||||
single.data?.incomeTotal,
|
||||
);
|
||||
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
|
||||
expect(upstream.getBondCoupons).not.toHaveBeenCalled();
|
||||
@@ -334,7 +331,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
expect(newRange.data).toEqual({
|
||||
...saved.data,
|
||||
events: [],
|
||||
incomeTotalsByCurrency: [],
|
||||
incomeTotal: { currency: 'RUB', value: '0' },
|
||||
});
|
||||
expect(newRange.data?.window).toEqual(window);
|
||||
await prisma.tbankConnection.update({
|
||||
@@ -372,8 +369,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
}
|
||||
expect(
|
||||
((await events().expect(200)).body as UpcomingEventsResourceDto).data
|
||||
?.incomeTotalsByCurrency,
|
||||
).toEqual([{ currency: 'USD', value: '90.000000009' }]);
|
||||
?.incomeTotal,
|
||||
).toEqual({ currency: 'RUB', value: '0' });
|
||||
});
|
||||
|
||||
it('serves instrument payouts from shared calendars with private exact holdings and retains successful source timestamps on failure', async () => {
|
||||
@@ -539,9 +536,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
await settled();
|
||||
const result = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(result.data?.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '6' },
|
||||
]);
|
||||
expect(result.data?.incomeTotal).toEqual({ currency: 'RUB', value: '6' });
|
||||
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
|
||||
});
|
||||
|
||||
@@ -574,9 +569,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
await settled();
|
||||
const before = (await events().expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(before.data?.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '6' },
|
||||
]);
|
||||
expect(before.data?.incomeTotal).toEqual({ currency: 'RUB', value: '6' });
|
||||
const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||
where: { kind: 'bond-events' },
|
||||
});
|
||||
@@ -675,7 +668,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
const w1 = windowOf(day1, '2099-03-04');
|
||||
const dividend = (date: string) => ({
|
||||
recordDate: time(date),
|
||||
dividendNet: { units: '10', nano: 1, currency: 'usd' },
|
||||
dividendNet: { units: '10', nano: 1, currency: 'rub' },
|
||||
});
|
||||
const defaultEvents = async () =>
|
||||
(await get('brokerage-accounts/account/events').expect(200))
|
||||
@@ -705,9 +698,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
await refresh({});
|
||||
const first = await defaultEvents();
|
||||
expect(first.data?.window).toEqual(w0);
|
||||
expect(first.data?.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'USD', value: '20.000000002' },
|
||||
]);
|
||||
expect(first.data?.incomeTotal).toEqual({ currency: 'RUB', value: '20.000000002' });
|
||||
|
||||
jest.setSystemTime(new Date(`${day1}T12:00:00Z`));
|
||||
await portfolio([position(shareUid, '5')]);
|
||||
@@ -720,9 +711,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
'2099-02-05',
|
||||
'2099-03-04',
|
||||
]);
|
||||
expect(second.data?.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'USD', value: '100.00000001' },
|
||||
]);
|
||||
expect(second.data?.incomeTotal).toEqual({ currency: 'RUB', value: '100.00000001' });
|
||||
const stale = (await events('account', 'owner', w0).expect(200))
|
||||
.body as UpcomingEventsResourceDto;
|
||||
expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt);
|
||||
@@ -736,7 +725,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
expect(disjoint.data).toEqual({
|
||||
...second.data,
|
||||
events: [],
|
||||
incomeTotalsByCurrency: [],
|
||||
incomeTotal: { currency: 'RUB', value: '0' },
|
||||
});
|
||||
expect(await prisma.tbankForecast.count()).toBe(2);
|
||||
});
|
||||
@@ -779,7 +768,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
const dividend = (units: string, dividendType = 'Regular') => ({
|
||||
recordDate: time(date),
|
||||
dividendType,
|
||||
dividendNet: { units, currency: 'usd' },
|
||||
dividendNet: { units, currency: 'rub' },
|
||||
});
|
||||
// One hourly walk: the scheduler stamps it once and passes it to every account.
|
||||
const startWalk = async () => {
|
||||
@@ -803,16 +792,12 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
||||
it('picks up a changed and then a cancelled payout on the next pass of the same window', async () => {
|
||||
upstream.getDividends.mockResolvedValue([dividend('10')]);
|
||||
await pass(await startWalk());
|
||||
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'USD', value: '20' },
|
||||
]);
|
||||
expect((await latest()).data?.incomeTotal).toEqual({ currency: 'RUB', value: '20' });
|
||||
const first = await dividendRow();
|
||||
|
||||
upstream.getDividends.mockResolvedValue([dividend('30')]);
|
||||
await pass(await startWalk());
|
||||
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
|
||||
{ currency: 'USD', value: '60' },
|
||||
]);
|
||||
expect((await latest()).data?.incomeTotal).toEqual({ currency: 'RUB', value: '60' });
|
||||
expect((await dividendRow()).dataRevision).toBe(first.dataRevision + 1);
|
||||
|
||||
upstream.getDividends.mockResolvedValue([dividend('30', 'Cancelled')]);
|
||||
|
||||
Reference in New Issue
Block a user