refactor!: support RUB only (WealthLedger/main#55) (#204)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m32s
Backend verify / verify (push) Failing after 9m25s

BREAKING CHANGE: per-currency API fields replaced with single RUB Money.
This commit was merged in pull request #204.
This commit is contained in:
2026-11-11 10:07:11 +03:00
parent 85d65f8997
commit 809936e62d
61 changed files with 617 additions and 735 deletions
+1 -1
View File
@@ -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 });
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -98,7 +98,7 @@ describe('OpenAPI contract (integration)', () => {
expect.arrayContaining([
'accountId',
'period',
'totalsByCurrency',
'total',
'calculatedAt',
'bucketGranularity',
'buckets',
+25 -38
View File
@@ -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 },
],
},
});
+7 -14
View File
@@ -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();
+1 -1
View File
@@ -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',
});
+17 -25
View File
@@ -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,
+7 -13
View File
@@ -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({
+5 -5
View File
@@ -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,
},
+21 -25
View File
@@ -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);
});
});
});
+18 -33
View File
@@ -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')]);