From d6e6c4ace553572ff9e9f635b7a4d21c8afbf97f Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 26 Sep 2026 21:29:15 +0300 Subject: [PATCH 1/2] feat!: persist T-Bank data and synchronize reads Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints. --- .../migration.sql | 239 ++++ prisma/schema.prisma | 205 ++++ src/app.module.ts | 2 + .../tbank/instrument-metadata.repository.ts | 320 +++++ .../tbank/instrument-metadata.service.spec.ts | 294 ----- .../tbank/instrument-metadata.service.ts | 286 +++-- .../tbank/tbank-access.service.ts | 226 +++- .../tbank/tbank-membership.service.ts | 68 ++ .../tbank/tbank-source.policy.spec.ts | 33 + src/integrations/tbank/tbank-source.policy.ts | 26 + src/integrations/tbank/tbank.api.ts | 13 + src/integrations/tbank/tbank.client.ts | 8 +- src/integrations/tbank/tbank.module.ts | 5 + src/modules/investments/all-accounts.spec.ts | 429 ------- .../brokerage-account.dto.ts | 8 + .../brokerage-accounts-read.api.ts | 1 + .../brokerage-accounts.controller.ts | 14 +- .../brokerage-accounts.module.ts | 4 +- .../brokerage-accounts.service.spec.ts | 287 +---- .../brokerage-accounts.service.ts | 131 +- .../income/income-analytics.controller.ts | 40 +- .../income/income-analytics.dto.ts | 25 +- .../income/income-analytics.service.spec.ts | 62 +- .../income/income-analytics.service.ts | 139 ++- .../investments/income/income-read.api.ts | 1 + src/modules/investments/income/income.api.ts | 1 + .../investments/income/income.module.ts | 1 + .../instruments/application/candle.service.ts | 481 ++++++++ .../application/catalog-discovery.service.ts | 390 ++++++ .../application/catalog-enrichment.service.ts | 665 +++++++++++ .../application/catalog-selection.ts | 39 + .../bonds/application/bond-catalog.service.ts | 248 +--- .../bonds/application/bond-income.service.ts | 399 ++++--- .../bonds/application/bond-market.service.ts | 260 ++-- .../bonds/application/bond.spec.ts | 617 +++------- .../bonds/presentation/bond.controller.ts | 70 +- .../bonds/presentation/bond.dto.ts | 49 + .../domain/candle-coverage.spec.ts | 73 ++ .../instruments/domain/candle-coverage.ts | 90 ++ .../infrastructure/candle.repository.ts | 306 +++++ .../catalog-discovery.repository.ts | 480 ++++++++ .../instruments/instrument-income.api.ts | 2 + .../instruments/instrument-sources.module.ts | 22 + .../instruments/instruments.api.ts | 5 + .../instruments/instruments.module.ts | 6 +- .../application/get-stock-company.use-case.ts | 74 +- .../application/stock-catalog.service.ts | 238 ++-- .../application/stock-income.service.ts | 232 ++-- .../application/stock-market.service.ts | 242 ++-- .../stocks/application/stock.spec.ts | 415 +------ .../stocks/presentation/stock.controller.ts | 78 +- .../stocks/presentation/stock.dto.ts | 57 +- .../investments/investment-calendar.ts | 13 +- .../investments/investment-resource.dto.ts | 75 ++ .../investment-resource.mapper.spec.ts | 80 ++ .../investments/investment-resource.mapper.ts | 87 ++ .../investments/investment-sync-plan.spec.ts | 189 +++ .../investments/investment-sync-plan.ts | 330 +++++ .../brokerage-operations-sync.service.ts | 294 +++++ .../brokerage-operations.service.spec.ts | 373 ------ .../brokerage-operations.service.ts | 1062 +++++++++-------- .../domain/operations-sync-plan.spec.ts | 141 +++ .../operations/domain/operations-sync-plan.ts | 183 +++ .../brokerage-operations.repository.ts | 922 ++++++++++++++ .../investments/operations/operations.api.ts | 8 + .../operations/operations.module.ts | 10 +- .../presentation/brokerage-operation.dto.ts | 13 +- .../brokerage-operations.controller.spec.ts | 4 +- .../brokerage-operations.controller.ts | 10 +- .../portfolio/application/forecast.service.ts | 89 ++ .../application/portfolio.service.spec.ts | 512 +++----- .../application/portfolio.service.ts | 496 ++++++-- .../infrastructure/forecast.repository.ts | 154 +++ .../infrastructure/portfolio.repository.ts | 157 +++ .../investments/portfolio/portfolio.api.ts | 2 + .../investments/portfolio/portfolio.module.ts | 15 +- .../presentation/portfolio.controller.ts | 27 +- .../portfolio/presentation/portfolio.dto.ts | 8 + .../tbank-connection.module.ts | 3 +- .../tbank-connection.service.spec.ts | 122 -- .../tbank-connection.service.ts | 41 +- .../tbank-sync/tbank-screen.spec.ts | 65 + .../investments/tbank-sync/tbank-screen.ts | 101 ++ .../tbank-sync/tbank-sync.controller.ts | 77 ++ .../investments/tbank-sync/tbank-sync.dto.ts | 112 ++ .../tbank-sync/tbank-sync.module.ts | 29 + .../tbank-sync/tbank-sync.repository.ts | 121 ++ .../tbank-sync/tbank-sync.service.ts | 595 +++++++++ .../upcoming-events-read.api.ts | 1 + .../upcoming-events.controller.ts | 6 +- .../upcoming-events/upcoming-events.dto.ts | 13 +- .../upcoming-events/upcoming-events.module.ts | 6 +- .../upcoming-events.service.spec.ts | 474 +------- .../upcoming-events.service.ts | 449 ++++--- test/all-accounts.e2e-spec.ts | 72 +- test/bank-cards-durability.cjs | 45 +- test/bonds.e2e-spec.ts | 32 +- test/fixtures/tbank-read-baseline.json | 666 +++++++++++ test/fixtures/tbank-read-persistent.json | 831 +++++++++++++ test/fixtures/tbank-sync-crash.cjs | 85 ++ test/portfolio-structure.e2e-spec.ts | 71 +- test/stocks.e2e-spec.ts | 26 +- test/tbank-candles-persistence.e2e-spec.ts | 379 ++++++ test/tbank-catalog-discovery.e2e-spec.ts | 334 ++++++ test/tbank-catalog-local.e2e-spec.ts | 903 ++++++++++++++ test/tbank-connection.e2e-spec.ts | 263 ++++ test/tbank-income-local.e2e-spec.ts | 272 +++++ test/tbank-ledger-local.e2e-spec.ts | 342 ++++++ test/tbank-local-reads.e2e-spec.ts | 441 +++++++ test/tbank-metadata-persistence.e2e-spec.ts | 392 ++++++ test/tbank-migration.e2e-spec.ts | 253 ++++ test/tbank-operations-persistence.e2e-spec.ts | 350 ++++++ test/tbank-operations-sync.e2e-spec.ts | 714 +++++++++++ test/tbank-read-baseline.e2e-spec.ts | 569 +++++++++ test/tbank-screen-sync.e2e-spec.ts | 247 ++++ test/upcoming-events.e2e-spec.ts | 568 +++++++-- 116 files changed, 18321 insertions(+), 5409 deletions(-) create mode 100644 prisma/migrations/20260924170000_persist_tbank_data/migration.sql create mode 100644 src/integrations/tbank/instrument-metadata.repository.ts delete mode 100644 src/integrations/tbank/instrument-metadata.service.spec.ts create mode 100644 src/integrations/tbank/tbank-membership.service.ts create mode 100644 src/integrations/tbank/tbank-source.policy.spec.ts create mode 100644 src/integrations/tbank/tbank-source.policy.ts delete mode 100644 src/modules/investments/all-accounts.spec.ts create mode 100644 src/modules/investments/brokerage-accounts/brokerage-accounts-read.api.ts create mode 100644 src/modules/investments/income/income-read.api.ts create mode 100644 src/modules/investments/income/income.api.ts create mode 100644 src/modules/investments/instruments/application/candle.service.ts create mode 100644 src/modules/investments/instruments/application/catalog-discovery.service.ts create mode 100644 src/modules/investments/instruments/application/catalog-enrichment.service.ts create mode 100644 src/modules/investments/instruments/application/catalog-selection.ts create mode 100644 src/modules/investments/instruments/domain/candle-coverage.spec.ts create mode 100644 src/modules/investments/instruments/domain/candle-coverage.ts create mode 100644 src/modules/investments/instruments/infrastructure/candle.repository.ts create mode 100644 src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts create mode 100644 src/modules/investments/instruments/instrument-income.api.ts create mode 100644 src/modules/investments/instruments/instrument-sources.module.ts create mode 100644 src/modules/investments/instruments/instruments.api.ts create mode 100644 src/modules/investments/investment-resource.dto.ts create mode 100644 src/modules/investments/investment-resource.mapper.spec.ts create mode 100644 src/modules/investments/investment-resource.mapper.ts create mode 100644 src/modules/investments/investment-sync-plan.spec.ts create mode 100644 src/modules/investments/investment-sync-plan.ts create mode 100644 src/modules/investments/operations/application/brokerage-operations-sync.service.ts delete mode 100644 src/modules/investments/operations/application/brokerage-operations.service.spec.ts create mode 100644 src/modules/investments/operations/domain/operations-sync-plan.spec.ts create mode 100644 src/modules/investments/operations/domain/operations-sync-plan.ts create mode 100644 src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts create mode 100644 src/modules/investments/portfolio/application/forecast.service.ts create mode 100644 src/modules/investments/portfolio/infrastructure/forecast.repository.ts create mode 100644 src/modules/investments/portfolio/infrastructure/portfolio.repository.ts delete mode 100644 src/modules/investments/tbank-connection/tbank-connection.service.spec.ts create mode 100644 src/modules/investments/tbank-sync/tbank-screen.spec.ts create mode 100644 src/modules/investments/tbank-sync/tbank-screen.ts create mode 100644 src/modules/investments/tbank-sync/tbank-sync.controller.ts create mode 100644 src/modules/investments/tbank-sync/tbank-sync.dto.ts create mode 100644 src/modules/investments/tbank-sync/tbank-sync.module.ts create mode 100644 src/modules/investments/tbank-sync/tbank-sync.repository.ts create mode 100644 src/modules/investments/tbank-sync/tbank-sync.service.ts create mode 100644 src/modules/investments/upcoming-events/upcoming-events-read.api.ts create mode 100644 test/fixtures/tbank-read-baseline.json create mode 100644 test/fixtures/tbank-read-persistent.json create mode 100644 test/fixtures/tbank-sync-crash.cjs create mode 100644 test/tbank-candles-persistence.e2e-spec.ts create mode 100644 test/tbank-catalog-discovery.e2e-spec.ts create mode 100644 test/tbank-catalog-local.e2e-spec.ts create mode 100644 test/tbank-connection.e2e-spec.ts create mode 100644 test/tbank-income-local.e2e-spec.ts create mode 100644 test/tbank-ledger-local.e2e-spec.ts create mode 100644 test/tbank-local-reads.e2e-spec.ts create mode 100644 test/tbank-metadata-persistence.e2e-spec.ts create mode 100644 test/tbank-migration.e2e-spec.ts create mode 100644 test/tbank-operations-persistence.e2e-spec.ts create mode 100644 test/tbank-operations-sync.e2e-spec.ts create mode 100644 test/tbank-read-baseline.e2e-spec.ts create mode 100644 test/tbank-screen-sync.e2e-spec.ts diff --git a/prisma/migrations/20260924170000_persist_tbank_data/migration.sql b/prisma/migrations/20260924170000_persist_tbank_data/migration.sql new file mode 100644 index 0000000..f5e3866 --- /dev/null +++ b/prisma/migrations/20260924170000_persist_tbank_data/migration.sql @@ -0,0 +1,239 @@ +PRAGMA foreign_keys=OFF; +BEGIN IMMEDIATE; + +-- CreateTable +CREATE TABLE "TbankAccount" ( + "userId" TEXT NOT NULL, + "revision" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "openedOn" TEXT, + "upstreamOrder" INTEGER NOT NULL, + "operationsRevision" INTEGER NOT NULL DEFAULT 0, + "historyFrom" DATETIME, + "historyBoundary" TEXT NOT NULL DEFAULT 'unknown', + + PRIMARY KEY ("userId", "revision", "accountId"), + CONSTRAINT "TbankAccount_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT +); + +-- CreateTable +CREATE TABLE "TbankPortfolio" ( + "userId" TEXT NOT NULL, + "revision" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "mappingVersion" INTEGER NOT NULL DEFAULT 1, + "fetchedAt" DATETIME NOT NULL, + "dataRevision" INTEGER NOT NULL DEFAULT 1, + + PRIMARY KEY ("userId", "revision", "accountId"), + CONSTRAINT "TbankPortfolio_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT +); + +-- CreateTable +CREATE TABLE "TbankOperation" ( + "userId" TEXT NOT NULL, + "revision" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "brokerOperationId" TEXT NOT NULL, + "occurredAt" DATETIME NOT NULL, + "instrumentUid" TEXT, + "category" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "mappingVersion" INTEGER NOT NULL DEFAULT 1, + + PRIMARY KEY ("userId", "revision", "accountId", "brokerOperationId"), + CONSTRAINT "TbankOperation_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT +); + +-- CreateTable +CREATE TABLE "TbankOperationCoverage" ( + "userId" TEXT NOT NULL, + "revision" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "instrumentPredicate" TEXT NOT NULL DEFAULT '', + "from" DATETIME NOT NULL, + "to" DATETIME NOT NULL, + "fetchedAt" DATETIME NOT NULL, + "asOf" DATETIME NOT NULL, + + PRIMARY KEY ("userId", "revision", "accountId", "instrumentPredicate", "from", "to"), + CONSTRAINT "TbankOperationCoverage_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT +); + +-- CreateTable +CREATE TABLE "TbankForecast" ( + "userId" TEXT NOT NULL, + "revision" TEXT NOT NULL, + "scopeKey" TEXT NOT NULL, + "membershipRevision" INTEGER NOT NULL, + "payload" JSONB NOT NULL, + "dependencyRevisions" JSONB NOT NULL, + "sourceTimestamps" JSONB NOT NULL, + "calculatedAt" DATETIME NOT NULL, + "dataRevision" INTEGER NOT NULL DEFAULT 1, + + PRIMARY KEY ("userId", "revision", "scopeKey"), + CONSTRAINT "TbankForecast_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT +); + +-- CreateTable +CREATE TABLE "TbankInstrument" ( + "provider" TEXT NOT NULL, + "uid" TEXT NOT NULL, + "figi" TEXT, + "ticker" TEXT, + "instrumentType" TEXT NOT NULL, + "metadata" JSONB NOT NULL, + "fetchedAt" DATETIME NOT NULL, + "dataRevision" INTEGER NOT NULL DEFAULT 1, + + PRIMARY KEY ("provider", "uid") +); + +-- CreateTable +CREATE TABLE "TbankCatalogDiscovery" ( + "provider" TEXT NOT NULL, + "instrumentType" TEXT NOT NULL, + "query" TEXT NOT NULL, + "instrumentUids" JSONB NOT NULL, + "fetchedAt" DATETIME NOT NULL, + "dataRevision" INTEGER NOT NULL DEFAULT 1, + + PRIMARY KEY ("provider", "instrumentType", "query") +); + +-- CreateTable +CREATE TABLE "TbankInstrumentLookup" ( + "provider" TEXT NOT NULL, + "identifierType" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "matchedUids" JSONB NOT NULL, + "fetchedAt" DATETIME NOT NULL, + "dataRevision" INTEGER NOT NULL DEFAULT 1, + + PRIMARY KEY ("provider", "identifierType", "identifier") +); + +-- CreateTable +CREATE TABLE "TbankInstrumentSource" ( + "provider" TEXT NOT NULL, + "instrumentUid" TEXT NOT NULL, + "source" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "rangeKey" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "fetchedAt" DATETIME NOT NULL, + "dataRevision" INTEGER NOT NULL DEFAULT 1, + + PRIMARY KEY ("provider", "instrumentUid", "source", "kind", "rangeKey"), + CONSTRAINT "TbankInstrumentSource_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "TbankCandle" ( + "provider" TEXT NOT NULL, + "instrumentUid" TEXT NOT NULL, + "interval" TEXT NOT NULL, + "time" DATETIME NOT NULL, + "close" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "isComplete" BOOLEAN NOT NULL, + "fetchedAt" DATETIME NOT NULL, + + PRIMARY KEY ("provider", "instrumentUid", "interval", "time"), + CONSTRAINT "TbankCandle_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "TbankCandleCoverage" ( + "provider" TEXT NOT NULL, + "instrumentUid" TEXT NOT NULL, + "interval" TEXT NOT NULL, + "from" DATETIME NOT NULL, + "to" DATETIME NOT NULL, + "isComplete" BOOLEAN NOT NULL, + "fetchedAt" DATETIME NOT NULL, + "dataRevision" INTEGER NOT NULL DEFAULT 1, + + PRIMARY KEY ("provider", "instrumentUid", "interval", "from", "to"), + CONSTRAINT "TbankCandleCoverage_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "TbankSyncWork" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "revision" TEXT NOT NULL, + "scopeKey" TEXT NOT NULL, + "intent" JSONB NOT NULL, + "state" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "processRunId" TEXT NOT NULL, + "priority" INTEGER NOT NULL, + "progress" JSONB NOT NULL, + "errorCode" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "TbankSyncWork_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT +); + +-- CreateTable +CREATE TABLE "TbankSyncStage" ( + "workId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "sourceKey" TEXT NOT NULL, + "itemKey" TEXT NOT NULL, + "payload" JSONB NOT NULL, + + PRIMARY KEY ("workId", "attemptId", "sourceKey", "itemKey"), + CONSTRAINT "TbankSyncStage_workId_attemptId_fkey" FOREIGN KEY ("workId", "attemptId") REFERENCES "TbankSyncWork" ("id", "attemptId") ON DELETE CASCADE ON UPDATE RESTRICT +); + +-- RedefineTables +CREATE TABLE "new_TbankConnection" ( + "userId" TEXT NOT NULL, + "encryptedToken" TEXT NOT NULL, + "connectedAt" DATETIME NOT NULL, + "revision" TEXT NOT NULL, + "invalidToken" BOOLEAN NOT NULL DEFAULT false, + "membershipAt" DATETIME, + "membershipRevision" INTEGER NOT NULL DEFAULT 0, + CONSTRAINT "TbankConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +-- Preserve credentials and assign an opaque revision to each existing connection. +INSERT INTO "new_TbankConnection" ("connectedAt", "encryptedToken", "userId", "revision") SELECT "connectedAt", "encryptedToken", "userId", lower(hex(randomblob(16))) FROM "TbankConnection"; +DROP TABLE "TbankConnection"; +ALTER TABLE "new_TbankConnection" RENAME TO "TbankConnection"; +CREATE UNIQUE INDEX "TbankConnection_userId_key" ON "TbankConnection"("userId"); +CREATE UNIQUE INDEX "TbankConnection_userId_revision_key" ON "TbankConnection"("userId", "revision"); + +-- CreateIndex +CREATE INDEX "TbankOperation_userId_revision_accountId_occurredAt_brokerOperationId_idx" ON "TbankOperation"("userId", "revision", "accountId", "occurredAt", "brokerOperationId"); + +-- CreateIndex +CREATE INDEX "TbankOperation_userId_revision_accountId_instrumentUid_occurredAt_idx" ON "TbankOperation"("userId", "revision", "accountId", "instrumentUid", "occurredAt"); + +-- CreateIndex +CREATE INDEX "TbankOperation_userId_revision_accountId_category_occurredAt_idx" ON "TbankOperation"("userId", "revision", "accountId", "category", "occurredAt"); + +-- CreateIndex +CREATE INDEX "TbankInstrument_provider_figi_idx" ON "TbankInstrument"("provider", "figi"); + +-- CreateIndex +CREATE INDEX "TbankInstrument_provider_ticker_instrumentType_idx" ON "TbankInstrument"("provider", "ticker", "instrumentType"); + +-- CreateIndex +CREATE INDEX "TbankSyncWork_userId_revision_state_priority_idx" ON "TbankSyncWork"("userId", "revision", "state", "priority"); + +-- CreateIndex +CREATE UNIQUE INDEX "TbankSyncWork_userId_revision_scopeKey_key" ON "TbankSyncWork"("userId", "revision", "scopeKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "TbankSyncWork_id_attemptId_key" ON "TbankSyncWork"("id", "attemptId"); + + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f86c636..b13c7e5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,7 +25,212 @@ model TbankConnection { userId String @unique encryptedToken String connectedAt DateTime + revision String @default(uuid()) + invalidToken Boolean @default(false) + membershipAt DateTime? + membershipRevision Int @default(0) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accounts TbankAccount[] + forecasts TbankForecast[] + work TbankSyncWork[] + + @@unique([userId, revision]) +} + +model TbankAccount { + userId String + revision String + accountId String + type String + name String + openedOn String? + upstreamOrder Int + operationsRevision Int @default(0) + historyFrom DateTime? + historyBoundary String @default("unknown") + connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict) + portfolio TbankPortfolio? + operations TbankOperation[] + coverage TbankOperationCoverage[] + + @@id([userId, revision, accountId]) +} + +model TbankPortfolio { + userId String + revision String + accountId String + payload Json + mappingVersion Int @default(1) + fetchedAt DateTime + dataRevision Int @default(1) + account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict) + + @@id([userId, revision, accountId]) +} + +model TbankOperation { + userId String + revision String + accountId String + brokerOperationId String + occurredAt DateTime + instrumentUid String? + category String + payload Json + mappingVersion Int @default(1) + account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict) + + @@id([userId, revision, accountId, brokerOperationId]) + @@index([userId, revision, accountId, occurredAt, brokerOperationId]) + @@index([userId, revision, accountId, instrumentUid, occurredAt]) + @@index([userId, revision, accountId, category, occurredAt]) +} + +model TbankOperationCoverage { + userId String + revision String + accountId String + instrumentPredicate String @default("") + from DateTime + to DateTime + fetchedAt DateTime + asOf DateTime + account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict) + + @@id([userId, revision, accountId, instrumentPredicate, from, to]) +} + +model TbankForecast { + userId String + revision String + scopeKey String + membershipRevision Int + payload Json + dependencyRevisions Json + sourceTimestamps Json + calculatedAt DateTime + dataRevision Int @default(1) + connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict) + + @@id([userId, revision, scopeKey]) +} + +model TbankInstrument { + provider String + uid String + figi String? + ticker String? + instrumentType String + metadata Json + fetchedAt DateTime + dataRevision Int @default(1) + sources TbankInstrumentSource[] + candles TbankCandle[] + candleCoverage TbankCandleCoverage[] + + @@id([provider, uid]) + @@index([provider, figi]) + @@index([provider, ticker, instrumentType]) +} + +model TbankCatalogDiscovery { + provider String + instrumentType String + query String + instrumentUids Json + fetchedAt DateTime + dataRevision Int @default(1) + + @@id([provider, instrumentType, query]) +} + +// Empty and ambiguous successful UID/FIGI/ticker lookups are distinct from failures. +model TbankInstrumentLookup { + provider String + identifierType String + identifier String + matchedUids Json + fetchedAt DateTime + dataRevision Int @default(1) + + @@id([provider, identifierType, identifier]) +} + +// Source records contain no holdings, personal estimates or credentials errors. +model TbankInstrumentSource { + provider String + instrumentUid String + source String + kind String + rangeKey String + payload Json + fetchedAt DateTime + dataRevision Int @default(1) + instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade) + + @@id([provider, instrumentUid, source, kind, rangeKey]) +} + +model TbankCandle { + provider String + instrumentUid String + interval String + time DateTime + close String + payload Json + isComplete Boolean + fetchedAt DateTime + instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade) + + @@id([provider, instrumentUid, interval, time]) +} + +model TbankCandleCoverage { + provider String + instrumentUid String + interval String + from DateTime + to DateTime + isComplete Boolean + fetchedAt DateTime + dataRevision Int @default(1) + instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade) + + @@id([provider, instrumentUid, interval, from, to]) +} + +model TbankSyncWork { + id String @id + userId String + revision String + scopeKey String + intent Json + state String + attemptId String + processRunId String + priority Int + progress Json + errorCode String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict) + staged TbankSyncStage[] + + @@unique([userId, revision, scopeKey]) + @@index([userId, revision, state, priority]) + @@unique([id, attemptId]) +} + +model TbankSyncStage { + workId String + attemptId String + sourceKey String + itemKey String + payload Json + work TbankSyncWork @relation(fields: [workId, attemptId], references: [id, attemptId], onDelete: Cascade, onUpdate: Restrict) + + @@id([workId, attemptId, sourceKey, itemKey]) } model Session { diff --git a/src/app.module.ts b/src/app.module.ts index cbb0f78..a5e7443 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,3 +1,4 @@ +import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { LoggerModule } from 'nestjs-pino'; @@ -64,6 +65,7 @@ import { PrismaModule } from './prisma/prisma.module'; UpcomingEventsModule, InstrumentsModule, TbankConnectionModule, + TbankSyncModule, ], }) export class AppModule {} diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts new file mode 100644 index 0000000..9e66b73 --- /dev/null +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -0,0 +1,320 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import type { + Prisma, + TbankInstrumentLookup as MetadataLookupRow, + TbankInstrument as MetadataRow, +} from '../../../generated/prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { TbankSyncAttempt } from './tbank-access.service'; +import type { TbankInstrument } from './tbank.client'; +import { tbankSourceTtlMs } from './tbank-source.policy'; + +export type MetadataIdentity = { + instrumentUid?: string | null; + figi?: string | null; +}; +export type MetadataLookup = { + type: 'uid' | 'figi'; + id: string; + data: TbankInstrument | null; +}; +const provider = 'tbank'; +// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates. +const metadataReadBatchSize = 400; +export function metadataKey(identity: MetadataIdentity) { + return JSON.stringify([ + identity.instrumentUid?.trim() || null, + identity.figi?.trim() || null, + ]); +} + +@Injectable() +export class InstrumentMetadataRepository { + constructor(private readonly prisma: PrismaService) {} + + async readMany(identities: MetadataIdentity[]) { + const identifiers = identities.flatMap((identity) => [ + ...(identity.instrumentUid?.trim() + ? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] + : []), + ...(identity.figi?.trim() + ? [{ identifierType: 'figi', identifier: identity.figi.trim() }] + : []), + ]); + if (!identifiers.length) + return new Map>(); + return this.prisma.$transaction(async (tx) => { + const lookups: MetadataLookupRow[] = []; + for (const identifierType of ['uid', 'figi']) { + const values = [ + ...new Set( + identifiers + .filter((entry) => entry.identifierType === identifierType) + .map((entry) => entry.identifier), + ), + ]; + for ( + let offset = 0; + offset < values.length; + offset += metadataReadBatchSize + ) + lookups.push( + ...(await tx.tbankInstrumentLookup.findMany({ + where: { + provider, + identifierType, + identifier: { + in: values.slice(offset, offset + metadataReadBatchSize), + }, + }, + })), + ); + } + const uids = [ + ...new Set(lookups.flatMap((row) => row.matchedUids as string[])), + ]; + const instruments: MetadataRow[] = []; + for ( + let offset = 0; + offset < uids.length; + offset += metadataReadBatchSize + ) + instruments.push( + ...(await tx.tbankInstrument.findMany({ + where: { + provider, + uid: { in: uids.slice(offset, offset + metadataReadBatchSize) }, + }, + })), + ); + const byIdentifier = new Map( + lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]), + ); + const byUid = new Map(instruments.map((row) => [row.uid, row])); + return new Map( + identities.map((identity) => [ + metadataKey(identity), + selectMetadata(identity, byIdentifier, byUid), + ]), + ); + }); + } + + async reserve( + attemptOwner: { userId: string; revision: string }, + identity: MetadataIdentity, + runId: string, + active: ReadonlySet, + ) { + return this.prisma.$transaction(async (tx) => { + const valid = await tx.tbankConnection.updateMany({ + where: { ...attemptOwner, invalidToken: false }, + data: { membershipRevision: { increment: 0 } }, + }); + if (!valid.count) return null; + const scope = { + ...attemptOwner, + scopeKey: `metadata:${metadataKey(identity)}`, + }; + const previous = await tx.tbankSyncWork.findUnique({ + where: { userId_revision_scopeKey: scope }, + }); + if ( + previous?.state === 'running' && + previous.processRunId === runId && + active.has(previous.attemptId) + ) + return { work: previous, launch: false }; + const data = { + attemptId: randomUUID(), + processRunId: runId, + state: 'running', + priority: 1, + intent: { source: 'metadata', ...identity }, + progress: {}, + errorCode: null, + }; + const work = await tx.tbankSyncWork.upsert({ + where: { userId_revision_scopeKey: scope }, + create: { ...scope, ...data, id: randomUUID() }, + update: data, + }); + return { work, launch: true }; + }); + } + + work(userId: string, revision: string, identity: MetadataIdentity) { + return this.prisma.tbankSyncWork.findUnique({ + where: { + userId_revision_scopeKey: { + userId, + revision, + scopeKey: `metadata:${metadataKey(identity)}`, + }, + }, + select: { id: true, state: true, errorCode: true }, + }); + } + + publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) { + return this.prisma.$transaction(async (tx) => { + const claimed = await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { state: 'complete', progress: {} }, + }); + if (!claimed.count) return false; + const aliases = new Map( + results.map((result) => [`${result.type}:${result.id}`, result]), + ); + for (const result of results) { + if (!result.data) continue; + const uid = + result.data.uid?.trim() || (result.type === 'uid' ? result.id : null); + const figi = + result.data.figi?.trim() || + (result.type === 'figi' ? result.id : null); + for (const [type, id] of [ + ['uid', uid], + ['figi', figi], + ] as const) { + if (id && uid && !aliases.has(`${type}:${id}`)) + aliases.set(`${type}:${id}`, { + type, + id, + data: { ...result.data, uid }, + }); + } + } + for (const result of aliases.values()) { + const instrument = result.data; + const uid = + instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null); + // FIGI is an alias, never a fabricated provider UID. + if (instrument && !uid) + throw new Error('Metadata response has no instrument UID'); + if (instrument && uid) { + const key = { provider, uid }; + const previous = await tx.tbankInstrument.findUnique({ + where: { provider_uid: key }, + }); + const payload = JSON.parse( + JSON.stringify(instrument), + ) as Prisma.InputJsonObject; + const data = { + metadata: payload, + figi: + instrument.figi ?? (result.type === 'figi' ? result.id : null), + ticker: instrument.ticker ?? null, + instrumentType: instrument.instrumentType ?? '', + fetchedAt: now, + }; + await tx.tbankInstrument.upsert({ + where: { provider_uid: key }, + create: { ...key, ...data }, + update: { + ...data, + dataRevision: { + increment: isDeepStrictEqual(previous?.metadata, payload) + ? 0 + : 1, + }, + }, + }); + } + const key = { + provider, + identifierType: result.type, + identifier: result.id, + }; + const matchedUids = instrument && uid ? [uid] : []; + const previous = await tx.tbankInstrumentLookup.findUnique({ + where: { provider_identifierType_identifier: key }, + }); + await tx.tbankInstrumentLookup.upsert({ + where: { provider_identifierType_identifier: key }, + create: { ...key, matchedUids, fetchedAt: now }, + update: { + matchedUids, + fetchedAt: now, + dataRevision: { + increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) + ? 0 + : 1, + }, + }, + }); + } + return true; + }); + } + + async finishObserver(attempt: TbankSyncAttempt, success: boolean) { + await this.prisma.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + ...(success ? { connection: { invalidToken: false } } : {}), + }, + data: { + state: success ? 'complete' : 'failed', + errorCode: success ? null : 'TBANK_UNAVAILABLE', + }, + }); + if (success) + await this.prisma.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: true }, + }, + data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + }); + } +} + +function selectMetadata( + identity: MetadataIdentity, + lookups: ReadonlyMap, + instruments: ReadonlyMap, +) { + const resolve = (type: string, id: string | null | undefined) => { + const lookup = lookups.get(`${type}:${id?.trim()}`); + const uid = (lookup?.matchedUids as string[] | undefined)?.[0]; + const instrument = uid ? instruments.get(uid) : undefined; + const data = instrument ? (instrument.metadata as TbankInstrument) : null; + return { lookup, instrument, data }; + }; + const instrumentUid = identity.instrumentUid?.trim(); + const instrumentFigi = identity.figi?.trim(); + const uid = resolve('uid', instrumentUid); + const figi = resolve('figi', instrumentFigi); + const usefulUid = Boolean(uid.data?.name?.trim() || uid.data?.ticker?.trim()); + const used = + usefulUid || !instrumentFigi ? [uid] : instrumentUid ? [uid, figi] : [figi]; + const complete = used.every((row) => Boolean(row.lookup)); + const fetchedAt = + used + .flatMap((row) => (row.lookup ? [row.lookup.fetchedAt] : [])) + .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? null; + return { + data: usefulUid ? uid.data : (figi.data ?? uid.data), + complete, + fresh: + complete && + fetchedAt !== null && + Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata, + fetchedAt, + dataRevision: used + .map( + (row) => + `${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`, + ) + .join('/'), + }; +} diff --git a/src/integrations/tbank/instrument-metadata.service.spec.ts b/src/integrations/tbank/instrument-metadata.service.spec.ts deleted file mode 100644 index 2c7e027..0000000 --- a/src/integrations/tbank/instrument-metadata.service.spec.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { ServiceUnavailableException } from '@nestjs/common'; -import type { LRUCache } from 'lru-cache'; -import assert from 'node:assert/strict'; -import { InstrumentMetadataService } from './instrument-metadata.service'; -import type { TbankInstrument } from './tbank.client'; - -describe('InstrumentMetadataService', () => { - const tbank = { - getInstrumentByFigi: jest.fn(), - getInstrumentByUid: jest.fn(), - }; - const logger = { event: jest.fn() }; - let service: InstrumentMetadataService; - - function rememberExpired(key: string, value: TbankInstrument): void { - const cache = ( - service as unknown as { metadata: LRUCache } - ).metadata; - cache.set(key, value, { ttl: 1, start: performance.now() - 1000 }); - } - - beforeEach(() => { - jest.clearAllMocks(); - tbank.getInstrumentByFigi.mockReset(); - tbank.getInstrumentByUid.mockReset(); - service = new InstrumentMetadataService(tbank as never, logger as never); - }); - - it('prefers the instrument UID when metadata is available', async () => { - tbank.getInstrumentByUid.mockResolvedValue({ - name: 'Инструмент', - brand: { logoName: 'instrument.png' }, - }); - - await expect( - service.resolve('token', 'uid-1', 'figi-1'), - ).resolves.toMatchObject({ - brand: { logoName: 'instrument.png' }, - }); - expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); - }); - - it('falls back to FIGI when the UID has no matching instrument', async () => { - tbank.getInstrumentByUid.mockResolvedValue(null); - tbank.getInstrumentByFigi.mockResolvedValue({ - ticker: 'SBER', - brand: { logoName: 'sber.png' }, - }); - - await expect( - service.resolve('token', 'uid-1', 'figi-1'), - ).resolves.toMatchObject({ - ticker: 'SBER', - }); - expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'figi-1'); - }); - - it('returns a nullable fallback when instrument metadata is unavailable', async () => { - tbank.getInstrumentByUid.mockRejectedValue(new Error('unavailable')); - - await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull(); - }); - - it('caches negative lookups and avoids repeated upstream requests', async () => { - tbank.getInstrumentByUid.mockResolvedValue(null); - - await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull(); - await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull(); - - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1); - }); - - it('tries FIGI when UID metadata has no usable name or ticker', async () => { - tbank.getInstrumentByUid.mockResolvedValue({ name: ' ', ticker: '' }); - tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Облигация' }); - await expect( - service.resolve('token', 'uid-1', 'figi-1'), - ).resolves.toMatchObject({ name: 'Облигация' }); - }); - - it('retains expired successful metadata through temporary failures and refreshes it later', async () => { - const known = { - name: 'Облигация', - ticker: 'BOND', - brand: { logoName: 'bond.png' }, - }; - tbank.getInstrumentByUid.mockResolvedValueOnce(known); - await service.resolve('token', 'uid-1', null); - rememberExpired('uid:uid-1', known); - tbank.getInstrumentByUid.mockRejectedValue( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual( - known, - ); - await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual( - known, - ); - tbank.getInstrumentByUid.mockResolvedValue({ - ...known, - name: 'Новое название', - }); - await expect( - service.resolve('token', 'uid-1', null), - ).resolves.toMatchObject({ name: 'Новое название' }); - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(4); - }); - - it('does not mask an invalid token as missing metadata or retry by FIGI', async () => { - const error = new Error('INVALID_TBANK_TOKEN'); - tbank.getInstrumentByUid.mockRejectedValue(error); - await expect(service.resolve('token', 'uid-1', 'figi-1')).rejects.toBe( - error, - ); - expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); - }); - - it('keeps known metadata through an incomplete refresh without renewing its freshness', async () => { - const known = { name: 'Облигация', ticker: 'BOND' }; - rememberExpired('uid:uid-1', known); - tbank.getInstrumentByUid - .mockResolvedValueOnce({ name: ' ', ticker: '', isin: 'RU0000000001' }) - .mockRejectedValueOnce( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - - await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual( - known, - ); - await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual( - known, - ); - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2); - expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); - }); - - it('keeps a known FIGI name after partial UID metadata and does not cache the partial response', async () => { - const known = { name: 'Облигация', ticker: 'BOND' }; - rememberExpired('figi:figi-1', known); - tbank.getInstrumentByUid - .mockResolvedValueOnce({ isin: 'RU0000000001' }) - .mockResolvedValueOnce({ name: 'Новое название' }); - tbank.getInstrumentByFigi.mockRejectedValue( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - - await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual( - known, - ); - await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual({ - name: 'Новое название', - }); - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2); - expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1); - }); - - it('returns cold partial fields without caching them', async () => { - const partial = { isin: 'RU0000000001' }; - tbank.getInstrumentByUid.mockResolvedValue(partial); - - await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual( - partial, - ); - await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual( - partial, - ); - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2); - }); - - it('replaces an expired name with authoritative confirmed absence', async () => { - rememberExpired('uid:uid-1', { name: 'Старое название' }); - tbank.getInstrumentByUid.mockResolvedValue(null); - - await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull(); - await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull(); - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1); - }); - - it('isolates in-flight credentials while sharing successful public metadata', async () => { - const error = new Error('INVALID_TBANK_TOKEN'); - const known = { name: 'Облигация' }; - let rejectInvalid: ((reason: Error) => void) | undefined; - tbank.getInstrumentByUid.mockImplementation((token: string) => - token === 'invalid-token' - ? new Promise((_resolve, reject) => { - rejectInvalid = reject; - }) - : Promise.resolve(known), - ); - - const invalid = expect( - service.resolve('invalid-token', 'uid-1', null), - ).rejects.toBe(error); - const valid = service.resolve('valid-token', 'uid-1', null); - assert.ok(rejectInvalid); - rejectInvalid(error); - - await invalid; - await expect(valid).resolves.toEqual(known); - await expect( - service.resolve('another-token', 'uid-1', null), - ).resolves.toEqual(known); - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2); - }); - - it('returns cached or nullable metadata without starting requests after abort', async () => { - const known = { name: 'Облигация' }; - rememberExpired('figi:figi-1', known); - const controller = new AbortController(); - controller.abort(); - - await expect( - service.resolve('token', 'uid-1', 'figi-1', controller.signal), - ).resolves.toEqual(known); - await expect( - service.resolve('token', 'uid-2', 'figi-2', controller.signal), - ).resolves.toBeNull(); - expect(tbank.getInstrumentByUid).not.toHaveBeenCalled(); - expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); - }); - - it('returns expired metadata promptly when the waiting caller aborts', async () => { - const known = { name: 'Облигация' }; - rememberExpired('uid:uid-1', known); - tbank.getInstrumentByUid.mockReturnValue(new Promise(() => {})); - const controller = new AbortController(); - const result = service.resolve( - 'token', - 'uid-1', - 'figi-1', - controller.signal, - ); - - controller.abort(); - - await expect(result).resolves.toEqual(known); - expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); - }); - - it('keeps a shared fetch alive after one caller aborts and caches its result', async () => { - const known = { name: 'Облигация' }; - let finish: ((value: TbankInstrument) => void) | undefined; - tbank.getInstrumentByUid.mockReturnValue( - new Promise((resolve) => { - finish = resolve; - }), - ); - const controller = new AbortController(); - const aborted = service.resolve( - 'token', - 'uid-1', - 'figi-1', - controller.signal, - ); - controller.abort(); - await expect(aborted).resolves.toBeNull(); - - const waiting = service.resolve('token', 'uid-1', null); - assert.ok(finish); - finish(known); - - await expect(waiting).resolves.toEqual(known); - await expect( - service.resolve('another-token', 'uid-1', null), - ).resolves.toEqual(known); - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1); - expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); - }); - - it('does not start FIGI lookup after an aborted UID lookup fails later', async () => { - let fail: ((reason: Error) => void) | undefined; - tbank.getInstrumentByUid.mockReturnValue( - new Promise((_resolve, reject) => { - fail = reject; - }), - ); - const controller = new AbortController(); - const result = service.resolve( - 'token', - 'uid-1', - 'figi-1', - controller.signal, - ); - controller.abort(); - await expect(result).resolves.toBeNull(); - - assert.ok(fail); - fail(new Error('INVALID_TBANK_TOKEN')); - await Promise.resolve(); - await Promise.resolve(); - - expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); - }); -}); diff --git a/src/integrations/tbank/instrument-metadata.service.ts b/src/integrations/tbank/instrument-metadata.service.ts index f78c86d..42fdf9c 100644 --- a/src/integrations/tbank/instrument-metadata.service.ts +++ b/src/integrations/tbank/instrument-metadata.service.ts @@ -1,118 +1,208 @@ -import { Injectable, ServiceUnavailableException } from '@nestjs/common'; -import { LRUCache } from 'lru-cache'; -import { createHash } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; import { StructuredLogger } from '../../common/logging/structured-logger.service'; +import { + TbankAccessService, + type TbankSyncAttempt, +} from './tbank-access.service'; import { TbankClient, type TbankInstrument } from './tbank.client'; +import { + InstrumentMetadataRepository, + metadataKey, + type MetadataIdentity, + type MetadataLookup, +} from './instrument-metadata.repository'; +export { metadataKey } from './instrument-metadata.repository'; +export type { MetadataIdentity } from './instrument-metadata.repository'; -const metadataForMs = 86_400_000; -const maxCachedInstruments = 500; -const missingInstrument = Symbol('missingInstrument'); - +type Producer = { + identity: MetadataIdentity; + attempt: TbankSyncAttempt; + observers: TbankSyncAttempt[]; +}; @Injectable() export class InstrumentMetadataService { - private readonly metadata = new LRUCache< - string, - TbankInstrument | typeof missingInstrument - >({ - max: maxCachedInstruments, - ttl: metadataForMs, - noDeleteOnStaleGet: true, - }); - private readonly pending = new Map>(); + private readonly runId = randomUUID(); + private readonly active = new Set(); + private readonly producers = new Map(); + private readonly queue: Producer[] = []; + private reservations: Promise = Promise.resolve(); + private running = false; constructor( private readonly tbank: TbankClient, private readonly logger: StructuredLogger, + private readonly repository: InstrumentMetadataRepository, + private readonly access: TbankAccessService, ) {} - async resolve( - token: string, - instrumentUid: string | null | undefined, - figi: string | null | undefined, - signal?: AbortSignal, - ): Promise { - const uid = instrumentUid?.trim(); - let byUid: TbankInstrument | null = null; - if (uid) { - byUid = await this.lookup( - token, - `uid:${uid}`, - () => this.tbank.getInstrumentByUid(token, uid), - signal, + savedMany(identities: MetadataIdentity[]) { + return this.repository.readMany(identities); + } + + async read( + userId: string, + identity: MetadataIdentity, + observe = false, + revision?: string, + ) { + const connection = await this.access.local(userId); + if (revision && connection.revision !== revision) return null; + const data = (await this.savedMany([identity])).get(metadataKey(identity)); + if (!observe && !data?.fresh) + await this.request(userId, connection.revision, identity); + return { + ...data, + work: await this.repository.work(userId, connection.revision, identity), + }; + } + + async request( + userId: string, + revision: string, + identity: MetadataIdentity, + force = false, + ) { + identity = { + instrumentUid: identity.instrumentUid?.trim() || null, + figi: identity.figi?.trim() || null, + }; + if (!identity.instrumentUid && !identity.figi) return null; + const normalized = identity; + const reserved = this.reservations.then(async () => { + const snapshot = (await this.savedMany([normalized])).get( + metadataKey(normalized), ); - if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid; - } - const instrumentFigi = figi?.trim(); - const byFigi = instrumentFigi - ? await this.lookup( - token, - `figi:${instrumentFigi}`, - () => this.tbank.getInstrumentByFigi(token, instrumentFigi), - signal, - ) - : null; - return byFigi ?? byUid; - } - - private async lookup( - token: string, - key: string, - fetch: () => Promise, - signal?: AbortSignal, - ): Promise { - const cached = this.metadata.get(key); - if (cached !== undefined) - return cached === missingInstrument ? null : cached; - if (signal?.aborted) return this.lastKnown(key); - const owner = createHash('sha256').update(token).digest('hex'); - const requestKey = `${owner}:${key}`; - let pending = this.pending.get(requestKey); - if (!pending) { - pending = this.fetch(key, fetch, this.lastKnown(key)).finally(() => { - this.pending.delete(requestKey); - }); - this.pending.set(requestKey, pending); - } - if (!signal) return pending; - return new Promise((resolve, reject) => { - const onAbort = () => resolve(this.lastKnown(key)); - signal.addEventListener('abort', onAbort, { once: true }); - void pending.then(resolve, reject).finally(() => { - signal.removeEventListener('abort', onAbort); - }); + if (!force && snapshot?.fresh) return null; + const result = await this.repository.reserve( + { userId, revision }, + normalized, + this.runId, + this.active, + ); + if (!result) return null; + const { id, attemptId } = result.work; + if (!result.launch) return id; + const attempt = { userId, revision, id, attemptId }; + this.active.add(attemptId); + const key = metadataKey(normalized); + const producer = this.producers.get(key); + if (producer) producer.observers.push(attempt); + else { + const queued = { identity: normalized, attempt, observers: [] }; + this.producers.set(key, queued); + this.queue.push(queued); + } + if (!this.running) { + this.running = true; + setImmediate(() => { + void this.drain(); + }); + } + return id; }); + this.reservations = reserved.catch(() => undefined); + return reserved; } - private lastKnown(key: string): TbankInstrument | null { - const value = this.metadata.peek(key, { allowStale: true }); - return value === missingInstrument ? null : (value ?? null); - } - - private async fetch( - key: string, - request: () => Promise, - previous: TbankInstrument | null, - ): Promise { + private async drain() { try { - const value = await request(); - const found = Boolean(value?.name?.trim() || value?.ticker?.trim()); - if (value === null || found) - this.metadata.set(key, value ?? missingInstrument); - this.logger.event('debug', 'instrument-metadata.resolved', { - found, + while (this.queue.length) { + const producer = this.queue.shift(); + if (!producer) break; + const { attempt, identity } = producer; + let success = false; + try { + const token = await this.access.tokenForAttempt(attempt); + if (token) { + const results: MetadataLookup[] = []; + let useful = false; + let uidFailed = false; + if (identity.instrumentUid) { + let data: TbankInstrument | null | undefined; + try { + data = await this.tbank.getInstrumentByUid( + token, + identity.instrumentUid, + ); + } catch (error) { + if ( + !identity.figi || + (error instanceof Error && + error.message === 'INVALID_TBANK_TOKEN') + ) + throw error; + uidFailed = true; + } + if (data?.uid && data.uid !== identity.instrumentUid) + throw new Error('Metadata UID mismatch'); + if (data !== undefined) + results.push({ type: 'uid', id: identity.instrumentUid, data }); + useful = Boolean(data?.name?.trim() || data?.ticker?.trim()); + if (!useful && data && !identity.figi) + throw new Error('Incomplete metadata'); + } + if (!useful && identity.figi) { + const data = await this.tbank.getInstrumentByFigi( + token, + identity.figi, + ); + if (data?.figi && data.figi !== identity.figi) + throw new Error('Metadata FIGI mismatch'); + if (data && !data.name?.trim() && !data.ticker?.trim()) + throw new Error('Incomplete metadata'); + if (data === undefined || (uidFailed && !data)) + throw new Error('Metadata source unavailable'); + results.push({ type: 'figi', id: identity.figi, data }); + // A successful FIGI response identifies its canonical UID too; + // a failed UID RPC is never persisted as a negative lookup. + if ( + uidFailed && + data?.uid === identity.instrumentUid && + identity.instrumentUid + ) + results.push({ type: 'uid', id: identity.instrumentUid, data }); + } + if ( + !results.length || + (results.some((result) => result.data) && + !results.some( + (result) => + result.data?.name?.trim() || result.data?.ticker?.trim(), + )) + ) + throw new Error('Incomplete metadata'); + success = await this.repository.publish( + attempt, + results, + new Date(), + ); + } + } catch (error) { + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + await this.access.rejectMembershipToken(attempt); + else + this.logger.event('warn', 'instrument-metadata.failed', { + code: 'TBANK_UNAVAILABLE', + }); + } + // Remove the producer before awaiting finalization: a new request cannot + // attach to a producer whose observer list has already been drained. + this.producers.delete(metadataKey(identity)); + for (const observer of [attempt, ...producer.observers]) { + await this.repository.finishObserver(observer, success); + this.active.delete(observer.attemptId); + } + } + } catch { + this.logger.event('error', 'instrument-metadata.persistence-failed', { + code: 'TBANK_UNAVAILABLE', }); - return found || value === null ? value : (previous ?? value); - } catch (error) { - this.logger.event('warn', 'instrument-metadata.failed', { - code: - error instanceof Error && 'response' in error - ? ((error as { response?: { code?: string } }).response?.code ?? - 'UNKNOWN') - : 'UNKNOWN', - }); - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') - throw error; - return error instanceof ServiceUnavailableException ? previous : null; + this.active.clear(); + this.producers.clear(); + this.queue.length = 0; + } finally { + this.running = false; } } } diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index f7ac2f9..5d3a6c6 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -1,9 +1,11 @@ +import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { + BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { PrismaService } from '../../prisma/prisma.service'; import { SupportedTbankAccount, @@ -22,6 +24,13 @@ export type TbankAccountContext = TbankConnectionContext & { account: TbankAccount; }; +export type TbankSyncAttempt = { + userId: string; + revision: string; + id: string; + attemptId: string; +}; + @Injectable() export class TbankAccessService { constructor( @@ -30,6 +39,219 @@ export class TbankAccessService { private readonly tbank: TbankClient, ) {} + async reserveMembership( + userId: string, + revision: string, + processRunId: string, + active: ReadonlySet, + force: boolean, + ) { + return this.prisma.$transaction(async (tx) => { + const current = await tx.tbankConnection.updateMany({ + where: { userId, revision, invalidToken: false }, + data: { membershipRevision: { increment: 0 } }, + }); + if (!current.count) return null; + const connection = await tx.tbankConnection.findUniqueOrThrow({ + where: { userId }, + }); + if ( + !force && + !tbankSourceNeedsRefresh( + 'membership', + connection.membershipAt, + new Date(), + ) + ) + return null; + const scope = { userId, revision, scopeKey: 'membership' }; + const previous = await tx.tbankSyncWork.findUnique({ + where: { userId_revision_scopeKey: scope }, + }); + if ( + previous?.state === 'running' && + previous.processRunId === processRunId && + active.has(previous.attemptId) + ) + return { work: previous, launch: false }; + const data = { + attemptId: randomUUID(), + processRunId, + state: 'running', + intent: { source: 'membership' }, + progress: {}, + priority: 0, + errorCode: null, + }; + const work = await tx.tbankSyncWork.upsert({ + where: { userId_revision_scopeKey: scope }, + create: { ...scope, ...data, id: randomUUID() }, + update: data, + }); + return { work, launch: true }; + }); + } + + membershipWork(userId: string, revision: string) { + return this.prisma.tbankSyncWork.findUnique({ + where: { + userId_revision_scopeKey: { userId, revision, scopeKey: 'membership' }, + }, + select: { id: true, state: true, errorCode: true, updatedAt: true }, + }); + } + + async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) { + if (invalidToken) return this.rejectMembershipToken(attempt); + const result = await this.prisma.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, + }); + return result.count === 1; + } + + async tokenForAttempt(attempt: TbankSyncAttempt): Promise { + const work = await this.prisma.tbankSyncWork.findFirst({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + select: { connection: { select: { encryptedToken: true } } }, + }); + return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; + } + + async publishMembership( + attempt: TbankSyncAttempt, + accounts: TbankAccount[], + fetchedAt: Date, + completeWork = false, + ): Promise { + const supported = supportedTbankAccounts(accounts); + if (new Set(supported.map(({ id }) => id)).size !== supported.length) + throw new Error('Duplicate T-Bank account identity'); + return this.prisma.$transaction(async (tx) => { + // Acquire the write fence before reading membership. Replacement and + // attempt supersession cannot interleave with the publication below. + const claimed = await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { updatedAt: new Date() }, + }); + if (!claimed.count) return false; + const { userId, revision } = attempt; + const previous = await tx.tbankAccount.findMany({ + where: { userId, revision }, + orderBy: { upstreamOrder: 'asc' }, + select: { accountId: true, type: true, name: true, openedOn: true }, + }); + const next = supported.map(({ id, ...account }) => ({ + accountId: id, + ...account, + })); + const changed = + previous.length !== next.length || + previous.some((account, index) => { + const incoming = next[index]; + return ( + account.accountId !== incoming.accountId || + account.type !== incoming.type || + account.name !== incoming.name || + account.openedOn !== incoming.openedOn + ); + }); + await tx.tbankAccount.deleteMany({ + where: { + userId, + revision, + accountId: { notIn: supported.map(({ id }) => id) }, + }, + }); + for (const [upstreamOrder, account] of next.entries()) { + await tx.tbankAccount.upsert({ + where: { + userId_revision_accountId: { + userId, + revision, + accountId: account.accountId, + }, + }, + create: { userId, revision, ...account, upstreamOrder }, + update: { ...account, upstreamOrder }, + }); + } + await tx.tbankConnection.update({ + where: { userId_revision: { userId, revision } }, + data: { + membershipAt: fetchedAt, + membershipRevision: { increment: changed ? 1 : 0 }, + }, + }); + if (completeWork) + await tx.tbankSyncWork.update({ + where: { id: attempt.id }, + data: { state: 'complete', progress: { accounts: supported.length } }, + }); + return true; + }); + } + + async rejectMembershipToken(attempt: TbankSyncAttempt): Promise { + return this.prisma.$transaction(async (tx) => { + const claimed = await tx.tbankSyncWork.updateMany({ + where: { ...attempt, state: 'running' }, + data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + }); + if (!claimed.count) return false; + await tx.tbankConnection.update({ + where: { + userId_revision: { + userId: attempt.userId, + revision: attempt.revision, + }, + }, + data: { invalidToken: true }, + }); + return true; + }); + } + + // This boundary neither decrypts credentials nor starts discovery/sync. + // Missing membership is distinct from a successfully observed empty list. + async local(userId: string, accountId?: string) { + const connection = await this.prisma.tbankConnection.findUnique({ + where: { userId }, + select: { + userId: true, + revision: true, + invalidToken: true, + membershipAt: true, + membershipRevision: true, + accounts: { orderBy: { upstreamOrder: 'asc' } }, + }, + }); + if (!connection) + throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); + if (connection.invalidToken) + throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + if ( + connection.membershipAt && + accountId && + accountId !== 'all' && + !connection.accounts.some((account) => account.accountId === accountId) + ) + throw new NotFoundException(); + return connection; + } + async listSupportedAccounts( userId: string, ): Promise { @@ -97,6 +319,8 @@ export class TbankAccessService { }); if (!connection) throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); + if (connection.invalidToken) + throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); return { token: this.tokens.decrypt(connection.encryptedToken), generation: createHash('sha256') diff --git a/src/integrations/tbank/tbank-membership.service.ts b/src/integrations/tbank/tbank-membership.service.ts new file mode 100644 index 0000000..0522c2b --- /dev/null +++ b/src/integrations/tbank/tbank-membership.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { StructuredLogger } from '../../common/logging/structured-logger.service'; +import { + TbankAccessService, + type TbankSyncAttempt, +} from './tbank-access.service'; +import { TbankClient } from './tbank.client'; + +@Injectable() +export class TbankMembershipService { + private readonly processRunId = randomUUID(); + private readonly active = new Map>(); + private reservations: Promise = Promise.resolve(); + + constructor( + private readonly access: TbankAccessService, + private readonly tbank: TbankClient, + private readonly logger: StructuredLogger, + ) {} + + request(userId: string, revision: string, force = false) { + const reservation = this.reservations.then(async () => { + const requested = await this.access.reserveMembership( + userId, + revision, + this.processRunId, + new Set(this.active.keys()), + force, + ); + if (!requested) return null; + const { id, attemptId } = requested.work; + if (requested.launch) { + const done = new Promise((resolve) => setImmediate(resolve)) + .then(() => this.execute({ userId, revision, id, attemptId })) + .catch(() => + this.logger.event('error', 'tbank.membership.persistence_failed', { + userId, + code: 'TBANK_UNAVAILABLE', + }), + ) + .finally(() => this.active.delete(attemptId)); + this.active.set(attemptId, done); + } + return { id, done: this.active.get(attemptId) ?? Promise.resolve() }; + }); + this.reservations = reservation.catch(() => undefined); + return reservation; + } + + private async execute(attempt: TbankSyncAttempt) { + try { + const token = await this.access.tokenForAttempt(attempt); + if (!token) return; + await this.access.publishMembership( + attempt, + await this.tbank.getOpenAccounts(token), + new Date(), + true, + ); + } catch (error) { + await this.access.failMembership( + attempt, + error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', + ); + } + } +} diff --git a/src/integrations/tbank/tbank-source.policy.spec.ts b/src/integrations/tbank/tbank-source.policy.spec.ts new file mode 100644 index 0000000..8952a4f --- /dev/null +++ b/src/integrations/tbank/tbank-source.policy.spec.ts @@ -0,0 +1,33 @@ +import { + tbankSourceNeedsRefresh, + tbankSourceTtlMs, +} from './tbank-source.policy'; + +describe('request-driven source freshness', () => { + it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])( + '%s expires at its boundary without changing source time', + (source) => { + const fetchedAt = new Date('2026-09-01Z'); + const ttl = tbankSourceTtlMs[source]; + expect( + tbankSourceNeedsRefresh( + source, + fetchedAt, + new Date(fetchedAt.valueOf() + ttl - 1000), + ), + ).toBe(false); + expect( + tbankSourceNeedsRefresh( + source, + fetchedAt, + new Date(fetchedAt.valueOf() + ttl), + ), + ).toBe(true); + expect(tbankSourceNeedsRefresh(source, fetchedAt, fetchedAt, true)).toBe( + true, + ); + expect(tbankSourceNeedsRefresh(source, null, fetchedAt)).toBe(true); + expect(fetchedAt.toISOString()).toBe('2026-09-01T00:00:00.000Z'); + }, + ); +}); diff --git a/src/integrations/tbank/tbank-source.policy.ts b/src/integrations/tbank/tbank-source.policy.ts new file mode 100644 index 0000000..39e3f54 --- /dev/null +++ b/src/integrations/tbank/tbank-source.policy.ts @@ -0,0 +1,26 @@ +// Freshness never establishes completeness. Immutable ranges are planned by +// their coverage owners; these TTLs apply only to mutable source snapshots. +export const tbankSourceTtlMs = { + membership: 300_000, + portfolio: 60_000, + quote: 60_000, + candleTail: 60_000, + operationsTail: 300_000, + metadata: 86_400_000, + catalog: 86_400_000, + company: 86_400_000, + calendar: 86_400_000, +} as const; + +export function tbankSourceNeedsRefresh( + source: keyof typeof tbankSourceTtlMs, + fetchedAt: Date | null, + now: Date, + force = false, +): boolean { + return ( + force || + !fetchedAt || + now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source] + ); +} diff --git a/src/integrations/tbank/tbank.api.ts b/src/integrations/tbank/tbank.api.ts index 4bedbe7..6d67a1d 100644 --- a/src/integrations/tbank/tbank.api.ts +++ b/src/integrations/tbank/tbank.api.ts @@ -7,17 +7,30 @@ export { export { type TbankBond, type TbankCandle, + type TbankCoupon, type TbankFundamentals, type TbankShare, } from './tbank-market.types'; export { TbankTokenService } from './tbank-token.service'; +export { supportedTbankAccounts } from './tbank-account.policy'; export { TbankClient, type TbankDividend, type TbankInstrument, type TbankMoney, type TbankOperationItem, + type TbankOperationsByCursorResponse, type TbankPortfolio, type TbankPositions, type TbankTimestamp, } from './tbank.client'; + +export { TbankMembershipService } from './tbank-membership.service'; + +export { + tbankSourceTtlMs, + tbankSourceNeedsRefresh, +} from './tbank-source.policy'; + +export { metadataKey } from './instrument-metadata.service'; +export type { MetadataIdentity } from './instrument-metadata.service'; diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 4c5b53c..893776c 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -66,6 +66,9 @@ export type TbankPortfolio = { }; export type TbankPositions = { money?: TbankMoney[] }; export type TbankInstrument = { + uid?: string; + figi?: string; + instrumentType?: string; ticker?: string; name?: string; isin?: string; @@ -80,6 +83,7 @@ export type TbankDividend = { }; export type TbankTimestamp = { seconds?: string | number; nanos?: number }; export type TbankOperationItem = { + state?: string; cursor?: string; id?: string; name?: string; @@ -233,8 +237,8 @@ export class TbankClient { this.logger.info('tbank.client.configured'); } - async validateToken(token: string): Promise { - await this.getOpenAccounts(token); + validateToken(token: string): Promise { + return this.getOpenAccounts(token); } getOpenAccounts(token: string): Promise { diff --git a/src/integrations/tbank/tbank.module.ts b/src/integrations/tbank/tbank.module.ts index 9be7b7f..fa7eed8 100644 --- a/src/integrations/tbank/tbank.module.ts +++ b/src/integrations/tbank/tbank.module.ts @@ -1,4 +1,6 @@ import { Module } from '@nestjs/common'; +import { TbankMembershipService } from './tbank-membership.service'; +import { InstrumentMetadataRepository } from './instrument-metadata.repository'; import { InstrumentMetadataService } from './instrument-metadata.service'; import { TbankAccessService } from './tbank-access.service'; import { TbankTokenService } from './tbank-token.service'; @@ -9,12 +11,15 @@ import { TbankClient } from './tbank.client'; TbankClient, TbankTokenService, TbankAccessService, + TbankMembershipService, InstrumentMetadataService, + InstrumentMetadataRepository, ], exports: [ TbankClient, TbankTokenService, TbankAccessService, + TbankMembershipService, InstrumentMetadataService, ], }) diff --git a/src/modules/investments/all-accounts.spec.ts b/src/modules/investments/all-accounts.spec.ts deleted file mode 100644 index 5a98e92..0000000 --- a/src/modules/investments/all-accounts.spec.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { - BadRequestException, - ConflictException, - ServiceUnavailableException, -} from '@nestjs/common'; -import assert from 'node:assert/strict'; - -jest.mock('../../integrations/tbank/tbank-access.service', () => ({ - TbankAccessService: class {}, -})); - -import type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service'; -import { IncomeAnalyticsService } from './income/income-analytics.service'; -import { BrokerageOperationsService } from './operations/application/brokerage-operations.service'; -import { PortfolioService } from './portfolio/application/portfolio.service'; -import { UpcomingEventsService } from './upcoming-events/upcoming-events.service'; - -function setup() { - const context = { - token: 'token', - generation: 'first', - accounts: [ - { id: 'a', name: 'Основной', openedOn: '2020-01-01' }, - { id: 'b', name: 'ИИС', openedOn: '2026-01-01' }, - ], - }; - const access = { - withSupportedAccounts: jest.fn( - (_user: string, handler: (value: typeof context) => unknown) => - handler(context), - ), - withAccountForConnection: jest.fn( - ( - _connection: TbankConnectionContext, - _id: string, - handler: () => unknown, - ) => handler(), - ), - }; - const tbank = { - getOperationsByCursor: jest.fn(), - getPortfolio: jest.fn().mockResolvedValue({ positions: [] }), - getPositions: jest.fn().mockResolvedValue({ money: [] }), - getDividends: jest.fn().mockResolvedValue([]), - getInstrumentByFigi: jest.fn().mockResolvedValue({ name: 'Облигация' }), - }; - const metadata = { resolve: jest.fn().mockResolvedValue(null) }; - const requestContext = { set: jest.fn() }; - const operations = new BrokerageOperationsService( - access as never, - tbank as never, - requestContext as never, - metadata as never, - ); - const portfolio = new PortfolioService( - access as never, - tbank as never, - requestContext as never, - metadata as never, - ); - const moex = { bondEvents: jest.fn().mockResolvedValue([]) }; - const events = new UpcomingEventsService( - access as never, - tbank as never, - moex as never, - requestContext as never, - { info: jest.fn(), event: jest.fn() } as never, - ); - return { - context, - access, - tbank, - operations, - portfolio, - events, - moex, - income: new IncomeAnalyticsService(operations), - }; -} - -const operation = ( - id: string, - second: number, - type = 'OPERATION_TYPE_COUPON', - value = 1, -) => ({ - id, - date: { seconds: String(second) }, - type, - payment: { currency: 'RUB', units: value }, -}); -const query = { from: '2025-01-01', to: '2026-12-31', limit: 2 }; - -describe('All accounts', () => { - it('does not truncate instrument history when an account opening date is missing', async () => { - const { context, operations, tbank } = setup(); - Object.assign(context.accounts[0], { openedOn: null }); - tbank.getOperationsByCursor.mockResolvedValue({ items: [] }); - await operations.getAll('user', { instrumentUid: 'common' }); - expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( - 'token', - expect.objectContaining({ - from: { seconds: '0' }, - instrumentId: 'common', - }), - ); - }); - - it('filters upstream by UID, keeps sold-position history and binds continuations to the instrument', async () => { - const { operations, tbank } = setup(); - tbank.getOperationsByCursor.mockImplementation( - ( - _token: string, - request: { instrumentId: string; accountId: string }, - ) => ({ - items: [ - { - ...operation( - request.accountId + '-dividend', - 9, - 'OPERATION_TYPE_DIVIDEND', - ), - instrumentUid: request.instrumentId, - }, - { - ...operation('wrong-paper', 8, 'OPERATION_TYPE_DIVIDEND'), - instrumentUid: 'preferred', - }, - ], - }), - ); - const first = await operations.getAll('user', { - instrumentUid: 'common', - limit: 1, - }); - assert.ok(first.nextCursor); - expect(first.items).toHaveLength(1); - expect(first.items[0].instrumentUid).toBe('common'); - expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( - 'token', - expect.objectContaining({ - instrumentId: 'common', - from: { seconds: '1577836800' }, - }), - ); - await expect( - operations.getAll('user', { - instrumentUid: 'preferred', - limit: 1, - cursor: first.nextCursor, - }), - ).rejects.toBeInstanceOf(BadRequestException); - const second = await operations.getAll('user', { - instrumentUid: 'common', - limit: 1, - cursor: first.nextCursor, - }); - expect(second.items[0].instrumentUid).toBe('common'); - expect(second.items[0].id).not.toBe(first.items[0].id); - expect(second.hasNext).toBe(false); - }); - - afterEach(() => jest.useRealTimers()); - - it('merges exact amounts by currency and keeps equal instruments on different accounts', async () => { - const { portfolio, tbank } = setup(); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'SAME', - instrumentType: 'share', - quantity: { units: 1 }, - currentPrice: { currency: 'RUB', nano: 100000000 }, - }, - ], - }); - tbank.getPositions.mockImplementation((_token: string, id: string) => ({ - money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }], - })); - const result = await portfolio.getAll('user'); - expect(result.structure.currencies).toMatchObject([ - { - currency: 'RUB', - totalValue: '0.4', - categories: [ - { type: 'STOCK', value: '0.2', sharePercent: '50' }, - { type: 'BOND', value: '0' }, - { type: 'OTHER', value: '0' }, - { type: 'CASH', value: '0.2', sharePercent: '50' }, - ], - }, - { currency: 'USD', totalValue: '0.2' }, - ]); - expect(result.totalsByCurrency).toEqual([ - { currency: 'RUB', value: '0.4' }, - { currency: 'USD', value: '0.2' }, - ]); - expect(result.cashBalances).toEqual([ - { currency: 'RUB', value: '0.2' }, - { currency: 'USD', value: '0.2' }, - ]); - expect(result.positions.map((position) => position.account?.id)).toEqual([ - 'a', - 'b', - ]); - }); - - it('rejects missing access and a partial portfolio instead of publishing incomplete totals', async () => { - const { portfolio, access, tbank } = setup(); - access.withSupportedAccounts.mockImplementationOnce(() => { - throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); - }); - await expect(portfolio.getAll('user')).rejects.toBeInstanceOf( - ConflictException, - ); - expect(tbank.getPortfolio).not.toHaveBeenCalled(); - tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException()); - await expect(portfolio.getAll('user')).rejects.toBeInstanceOf( - ServiceUnavailableException, - ); - }); - - it('marks an aggregate stale and uses its oldest snapshot timestamp', async () => { - jest.useFakeTimers().setSystemTime(new Date('2026-09-01T10:00:00Z')); - const { portfolio, tbank } = setup(); - const first = await portfolio.getAll('user'); - jest.setSystemTime(new Date('2026-09-01T10:01:00Z')); - tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException()); - const result = await portfolio.getAll('user'); - expect(result.isStale).toBe(true); - expect(result.lastSuccessfulAt).toBe(first.lastSuccessfulAt); - }); - - it('paginates interleaved streams without dropping buffered rows, preserving duplicate IDs', async () => { - const { operations, tbank } = setup(); - tbank.getOperationsByCursor.mockImplementation( - ( - _token: string, - { accountId, cursor }: { accountId: string; cursor?: string }, - ) => { - if (accountId === 'a') - return cursor - ? { items: [operation('last', 3)] } - : { - items: [operation('same', 9), operation('a2', 5)], - hasNext: true, - nextCursor: 'a-next', - }; - return { items: [operation('same', 8), operation('b2', 4)] }; - }, - ); - const first = await operations.getAll('user', query); - assert.ok(first.nextCursor); - const second = await operations.getAll('user', { - ...query, - cursor: first.nextCursor, - }); - const repeated = await operations.getAll('user', { - ...query, - cursor: first.nextCursor, - }); - assert.ok(second.nextCursor); - const third = await operations.getAll('user', { - ...query, - cursor: second.nextCursor, - }); - expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([ - ['same', 'a'], - ['same', 'b'], - ]); - expect(second.items.map((item) => item.id)).toEqual(['a2', 'b2']); - expect(repeated).toEqual(second); - expect(third.items.map((item) => item.id)).toEqual(['last']); - expect(third.hasNext).toBe(false); - }); - - it('continues through empty filtered pages and breaks ties by account', async () => { - const { operations, tbank } = setup(); - tbank.getOperationsByCursor.mockImplementation( - (_token: string, { cursor }: { cursor?: string }) => - cursor - ? { items: [operation('income', 5)] } - : { - items: [operation('fee', 8, 'OPERATION_TYPE_BROKER_FEE')], - hasNext: true, - nextCursor: 'next', - }, - ); - const page = await operations.getAll('user', { - ...query, - categories: ['income'], - }); - expect(page.items.map((item) => item.account?.id)).toEqual(['a', 'b']); - expect(page.items.every((item) => item.category === 'income')).toBe(true); - }); - - it('rejects damaged cursor chains and partial streams', async () => { - const { operations, tbank } = setup(); - tbank.getOperationsByCursor.mockResolvedValue({ - items: [], - hasNext: true, - nextCursor: 'loop', - }); - await expect(operations.getAll('user', query)).rejects.toBeInstanceOf( - ServiceUnavailableException, - ); - tbank.getOperationsByCursor.mockRejectedValue( - new ServiceUnavailableException(), - ); - await expect( - operations.getAll('another-user', query), - ).rejects.toBeInstanceOf(ServiceUnavailableException); - }); - - it('binds continuations to user, connection, accounts and filters', async () => { - const { operations, context, tbank } = setup(); - tbank.getOperationsByCursor.mockResolvedValue({ - items: [operation('first', 8), operation('last', 4)], - }); - const first = await operations.getAll('user', query); - assert.ok(first.nextCursor); - const next = { ...query, cursor: first.nextCursor }; - await expect(operations.getAll('other', next)).rejects.toBeInstanceOf( - BadRequestException, - ); - await expect( - operations.getAll('user', { ...next, categories: ['fee'] }), - ).rejects.toBeInstanceOf(BadRequestException); - context.generation = 'replaced'; - await expect(operations.getAll('user', next)).rejects.toBeInstanceOf( - BadRequestException, - ); - context.generation = 'first'; - context.accounts.pop(); - await expect(operations.getAll('user', next)).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2); - }); - - it('collects income across the same cohort and computes comparisons from all receipts', async () => { - const { income, tbank } = setup(); - tbank.getOperationsByCursor.mockImplementation( - ( - _token: string, - { accountId, from }: { accountId: string; from: { seconds: string } }, - ) => { - const year = new Date(Number(from.seconds) * 1000).getUTCFullYear(); - const second = - Date.parse( - year === 2025 ? '2025-08-15T12:00:00Z' : '2026-08-15T12:00:00Z', - ) / 1000; - return { - items: - year === 2025 && accountId === 'b' - ? [] - : [ - operation( - 'same', - second, - 'OPERATION_TYPE_COUPON', - year === 2025 ? 2 : 3, - ), - ], - }; - }, - ); - const result = await income.analytics( - 'user', - 'all', - '2026-08-01', - '2026-08-31', - ); - expect(result.totalsByCurrency[0].total.value).toBe('6'); - expect( - result.yearComparison?.totalsByCurrency[0].compared.total.value, - ).toBe('2'); - expect( - result.yearComparison?.totalsByCurrency[0].difference.total.value, - ).toBe('4'); - expect(result.entries.map((entry) => entry.account?.id)).toEqual([ - 'a', - 'b', - ]); - expect( - new Set(result.entries.map((entry) => entry.instrumentKey)).size, - ).toBe(2); - }); - - it('preserves separate future events and fails the section when one source fails', async () => { - const { events, tbank, moex } = setup(); - const today = new Date().toISOString().slice(0, 10); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'BOND', - ticker: 'BOND', - instrumentType: 'bond', - quantity: { units: 2 }, - }, - ], - }); - moex.bondEvents.mockResolvedValue([ - { type: 'coupon', date: today, value: '0.1', currency: 'RUB' }, - ]); - const result = await events.getAll('user'); - expect(result.events.map((event) => event.account?.id)).toEqual(['a', 'b']); - expect(result.incomeTotalsByCurrency).toEqual([ - { currency: 'RUB', value: '0.4' }, - ]); - tbank.getPortfolio - .mockResolvedValueOnce({ positions: [] }) - .mockRejectedValueOnce(new ServiceUnavailableException()); - await expect(events.getAll('user')).rejects.toBeInstanceOf( - ServiceUnavailableException, - ); - }); - - it('returns valid empty results for an empty account set', async () => { - const { context, portfolio, operations, income, events, tbank } = setup(); - context.accounts = []; - expect((await portfolio.getAll('user')).positions).toEqual([]); - expect((await operations.getAll('user', query)).hasNext).toBe(false); - expect((await income.summary('user', 'all')).totalsByCurrency).toEqual([]); - expect((await events.getAll('user')).events).toEqual([]); - expect(tbank.getOperationsByCursor).not.toHaveBeenCalled(); - expect(tbank.getPortfolio).not.toHaveBeenCalled(); - }); -}); diff --git a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts index f7e1252..22c8506 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts @@ -1,3 +1,4 @@ +import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { ApiProperty } from '@nestjs/swagger'; import { MoneyDto } from '../../../common/money.dto'; @@ -44,3 +45,10 @@ export class BrokerageAccountDto { @ApiProperty({ type: PortfolioSummaryDto, nullable: true }) portfolioSummary?: PortfolioSummaryDto | null; } + +export class BrokerageAccountsResourceDto { + @ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true }) + declare data: BrokerageAccountDto[] | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts-read.api.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts-read.api.ts new file mode 100644 index 0000000..c50abcf --- /dev/null +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts-read.api.ts @@ -0,0 +1 @@ +export { BrokerageAccountsService } from './brokerage-accounts.service'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts index 0207f51..564029c 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts @@ -1,9 +1,10 @@ -import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { InvestmentReadQueryDto } from '../investment-resource.dto'; +import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; -import { BrokerageAccountDto } from './brokerage-account.dto'; +import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsService } from './brokerage-accounts.service'; @ApiTags('brokerage-accounts') @@ -15,8 +16,11 @@ export class BrokerageAccountsController { constructor(private readonly accounts: BrokerageAccountsService) {} @Get() - @ApiOkResponse({ type: BrokerageAccountDto, isArray: true }) - list(@Req() request: Request & { user: AccessPayload }) { - return this.accounts.list(request.user.sub); + @ApiOkResponse({ type: BrokerageAccountsResourceDto }) + list( + @Req() request: Request & { user: AccessPayload }, + @Query() query: InvestmentReadQueryDto, + ) { + return this.accounts.list(request.user.sub, query.sync === 'observe'); } } diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts index e4016c0..267ea01 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts @@ -1,3 +1,4 @@ +import { PortfolioModule } from '../portfolio/portfolio.module'; import { Module } from '@nestjs/common'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; @@ -5,8 +6,9 @@ import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsService } from './brokerage-accounts.service'; @Module({ - imports: [AuthModule, TbankModule], + imports: [AuthModule, TbankModule, PortfolioModule], controllers: [BrokerageAccountsController], providers: [BrokerageAccountsService], + exports: [BrokerageAccountsService], }) export class BrokerageAccountsModule {} diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts index b754d65..2d20622 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -1,249 +1,70 @@ -import { - BadRequestException, - ConflictException, - ServiceUnavailableException, -} from '@nestjs/common'; - jest.mock('../../../prisma/prisma.service', () => ({ - PrismaService: class PrismaService {}, + PrismaService: class {}, +})); +import { BrokerageAccountsService } from './brokerage-accounts.service'; +import { investmentResourceMeta } from '../investment-resource.mapper'; + +jest.mock('../portfolio/application/portfolio.service', () => ({ + PortfolioService: class PortfolioService {}, })); -import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service'; -import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service'; -import { BrokerageAccountsService } from './brokerage-accounts.service'; +const meta = investmentResourceMeta({ + resourceKey: 'membership', + dataRevision: '1', + availability: 'complete', + fetchedAt: new Date(), + freshForMs: 300_000, + work: null, +}); -describe('BrokerageAccountsService', () => { - const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; - const prisma = { tbankConnection: { findUnique: jest.fn() } }; - const config = { getOrThrow: jest.fn(() => encryptionKey) }; - const tbank = { - getOpenAccounts: jest.fn(), - getPortfolio: jest.fn(), - getPositions: jest.fn(), - }; - const tokens = new TbankTokenService(config as never); - const access = new TbankAccessService( - prisma as never, - tokens, - tbank as never, - ); - const service = new BrokerageAccountsService(access, tbank as never); +describe('saved brokerage account projection', () => { + const portfolio = { saved: jest.fn() }; + const service = new BrokerageAccountsService(portfolio as never); - beforeEach(() => { - jest.clearAllMocks(); - tbank.getPortfolio.mockResolvedValue({ positions: [] }); - tbank.getPositions.mockResolvedValue({ money: [] }); - }); - - it('does not call T-Bank without a connection', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue(null); - - await expect(service.list('user-1')).rejects.toEqual( - expect.any(ConflictException), - ); - expect(tbank.getOpenAccounts).not.toHaveBeenCalled(); - }); - - it('normalizes supported accounts and ignores unsupported ones', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: tokens.encrypt('token'), + it('distinguishes unknown membership from a confirmed empty list', async () => { + portfolio.saved.mockResolvedValueOnce({ + membership: { ...meta, availability: 'missing' }, + accounts: [], }); - tbank.getOpenAccounts.mockResolvedValue([ - { - id: 'brokerage', - type: 'ACCOUNT_TYPE_TINKOFF', - name: ' Основной ', - openedDate: { seconds: '1723507200' }, - }, - { - id: 'debit', - type: 'ACCOUNT_TYPE_DEBIT', - name: 'Дебетовый', - }, - { - id: 'iis', - type: 'ACCOUNT_TYPE_TINKOFF_IIS', - name: ' ', - }, - ]); - - await expect(service.list('user-1')).resolves.toEqual([ - { - id: 'brokerage', - type: 'BROKERAGE', - name: 'Основной', - openedOn: '2024-08-13', - portfolioSummary: { - totalsByCurrency: [], - stockPositionCount: 0, - bondPositionCount: 0, - }, - }, - { - id: 'iis', - type: 'INDIVIDUAL_INVESTMENT', - name: 'ИИС', - openedOn: null, - portfolioSummary: { - totalsByCurrency: [], - stockPositionCount: 0, - bondPositionCount: 0, - }, - }, - ]); + expect((await service.list('owner', true)).data).toBeNull(); + portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] }); + expect((await service.list('owner', true)).data).toEqual([]); }); - it('keeps upstream order and adds exact summaries without loading metadata', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: tokens.encrypt('token'), - }); - tbank.getOpenAccounts.mockResolvedValue([ - { id: 'first', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' }, - { id: 'second', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'Второй' }, - ]); - tbank.getPortfolio - .mockResolvedValueOnce({ - positions: [ - { - quantity: { units: 2 }, - currentPrice: { currency: 'rub', units: 3 }, + it('keeps exact saved totals and independent missing summaries', async () => { + portfolio.saved.mockResolvedValue({ + membership: meta, + accounts: [ + { + id: 'a', + name: 'A', + type: 'BROKERAGE', + openedOn: null, + meta, + snapshot: { + totalsByCurrency: [ + { currency: 'RUB', value: '100000000.000000001' }, + ], + positions: [{ type: 'STOCK' }, { type: 'BOND' }], }, - ], - }) - .mockResolvedValueOnce({ positions: [] }); - tbank.getPositions - .mockResolvedValueOnce({ money: [{ currency: 'RUB', nano: 500000000 }] }) - .mockResolvedValueOnce({ money: [{ currency: 'USD', units: 4 }] }); - - await expect(service.list('user-1')).resolves.toEqual([ - { - id: 'first', - type: 'BROKERAGE', - name: 'Первый', - openedOn: null, - portfolioSummary: { - totalsByCurrency: [{ currency: 'RUB', value: '6.5' }], - stockPositionCount: 0, - bondPositionCount: 0, }, - }, - { - id: 'second', - type: 'INDIVIDUAL_INVESTMENT', - name: 'Второй', - openedOn: null, - portfolioSummary: { - totalsByCurrency: [{ currency: 'USD', value: '4' }], - stockPositionCount: 0, - bondPositionCount: 0, + { + id: 'b', + name: 'B', + type: 'BROKERAGE', + openedOn: null, + meta: { ...meta, availability: 'missing' }, + snapshot: null, }, - }, - ]); - expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'first'); - expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second'); - }); - - it('counts non-zero stock and bond positions even without a valuation', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: tokens.encrypt('token'), - }); - tbank.getOpenAccounts.mockResolvedValue([ - { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' }, - ]); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { instrumentType: 'share', quantity: { units: 200 } }, - { instrumentType: 'SHARE', quantity: { units: -5 } }, - { instrumentType: 'share', quantity: { units: 0, nano: 1 } }, - { instrumentType: 'bond', quantity: { units: 80 } }, - { instrumentType: 'bond', quantity: { units: 0 } }, - { instrumentType: 'share' }, - { instrumentType: 'etf', quantity: { units: 10 } }, - { instrumentType: 'currency', quantity: { units: 100 } }, - { quantity: { units: 1 } }, ], - virtualPositions: [{ instrumentType: 'bond', quantity: { units: 1 } }], }); - - await expect(service.list('user-1')).resolves.toMatchObject([ - { - portfolioSummary: { - totalsByCurrency: [], - stockPositionCount: 3, - bondPositionCount: 1, - }, - }, - ]); - }); - - it('localizes an unavailable summary without suppressing another account', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: tokens.encrypt('token'), + const response = await service.list('owner', true); + expect(response.data?.[0].portfolioSummary).toEqual({ + totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }], + stockPositionCount: 1, + bondPositionCount: 1, }); - tbank.getOpenAccounts.mockResolvedValue([ - { id: 'unavailable', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' }, - { id: 'available', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Второй' }, - ]); - tbank.getPortfolio - .mockRejectedValueOnce( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ) - .mockResolvedValueOnce({ positions: [] }); - tbank.getPositions.mockResolvedValue({ money: [] }); - - await expect(service.list('user-1')).resolves.toMatchObject([ - { id: 'unavailable', portfolioSummary: null }, - { id: 'available', portfolioSummary: { totalsByCurrency: [] } }, - ]); - }); - - it('does not mask an invalid token when the other summary input is unavailable', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: tokens.encrypt('token'), - }); - tbank.getOpenAccounts.mockResolvedValue([ - { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' }, - ]); - tbank.getPortfolio.mockRejectedValue( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - tbank.getPositions.mockRejectedValue(new Error('INVALID_TBANK_TOKEN')); - - await expect(service.list('user-1')).rejects.toEqual( - expect.any(BadRequestException), - ); - }); - - it('retains the connection when T-Bank rejects its token', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: tokens.encrypt('token'), - }); - tbank.getOpenAccounts.mockRejectedValue(new Error('INVALID_TBANK_TOKEN')); - - await expect(service.list('user-1')).rejects.toEqual( - expect.any(BadRequestException), - ); - }); - - it('returns an empty list from T-Bank unchanged', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: tokens.encrypt('token'), - }); - tbank.getOpenAccounts.mockResolvedValue([]); - - await expect(service.list('user-1')).resolves.toEqual([]); - }); - - it('propagates an unavailable T-Bank response', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: tokens.encrypt('token'), - }); - tbank.getOpenAccounts.mockRejectedValue( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - - await expect(service.list('user-1')).rejects.toEqual( - expect.any(ServiceUnavailableException), - ); + expect(response.data?.[1].portfolioSummary).toBeNull(); + expect(response.meta.availability).toBe('partial'); }); }); diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index d76424f..b8a0111 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -1,80 +1,65 @@ -import { Injectable, ServiceUnavailableException } from '@nestjs/common'; -import { isZero } from '../../../common/decimal'; -import { - TbankAccessService, - TbankClient, -} from '../../../integrations/tbank/tbank.api'; -import { decimal } from '../../../integrations/tbank/decimal'; -import { totalsByCurrency } from '../portfolio/portfolio.api'; +import { Injectable } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { PortfolioService } from '../portfolio/portfolio.api'; +import { investmentResourceMeta } from '../investment-resource.mapper'; import { BrokerageAccountDto } from './brokerage-account.dto'; @Injectable() export class BrokerageAccountsService { - constructor( - private readonly access: TbankAccessService, - private readonly tbank: TbankClient, - ) {} + constructor(private readonly portfolio: PortfolioService) {} - async list(userId: string): Promise { - return this.access.withSupportedAccounts( - userId, - async ({ token, accounts }) => - Promise.all( - accounts.map(async (account) => { - const [portfolioResult, positionsResult] = await Promise.allSettled( - [ - this.tbank.getPortfolio(token, account.id), - this.tbank.getPositions(token, account.id), - ], - ); - if ( - portfolioResult.status === 'rejected' && - !isTbankUnavailable(portfolioResult.reason) - ) - throw portfolioResult.reason; - if ( - positionsResult.status === 'rejected' && - !isTbankUnavailable(positionsResult.reason) - ) - throw positionsResult.reason; - if ( - portfolioResult.status === 'rejected' || - positionsResult.status === 'rejected' - ) - return { ...account, portfolioSummary: null }; - const positions = portfolioResult.value.positions ?? []; - let stockPositionCount = 0; - let bondPositionCount = 0; - for (const position of positions) { - if (isZero(decimal(position.quantity ?? {}))) continue; - const type = position.instrumentType?.toLowerCase(); - if (type === 'share') stockPositionCount += 1; - if (type === 'bond') bondPositionCount += 1; - } - return { - ...account, - portfolioSummary: { - totalsByCurrency: totalsByCurrency( - positions, - positionsResult.value.money ?? [], - ), - stockPositionCount, - bondPositionCount, - }, - }; - }), - ), - ); + async list(userId: string, observe = false) { + const saved = await this.portfolio.saved(userId, 'all', observe); + const data: BrokerageAccountDto[] | null = + saved.membership.availability === 'missing' + ? null + : saved.accounts.map((account) => ({ + id: account.id, + name: account.name, + type: account.type as BrokerageAccountDto['type'], + openedOn: account.openedOn, + portfolioSummary: account.snapshot + ? { + totalsByCurrency: account.snapshot.totalsByCurrency, + stockPositionCount: account.snapshot.positions.filter( + (position) => position.type === 'STOCK', + ).length, + bondPositionCount: account.snapshot.positions.filter( + (position) => position.type === 'BOND', + ).length, + } + : null, + })); + return { + data, + meta: investmentResourceMeta({ + resourceKey: 'accounts', + dataRevision: createHash('sha256') + .update( + JSON.stringify([ + saved.membership.dataRevision, + saved.accounts.map((account) => [ + account.id, + account.meta.dataRevision, + ]), + ]), + ) + .digest('base64url'), + availability: + data === null + ? 'missing' + : saved.accounts.some((account) => !account.snapshot) + ? 'partial' + : 'complete', + fetchedAt: saved.membership.lastSuccessfulAt + ? new Date(saved.membership.lastSuccessfulAt) + : null, + freshForMs: 300_000, + work: saved.membership.sync.id + ? { ...saved.membership.sync, id: saved.membership.sync.id } + : null, + dependencies: saved.accounts.map((account) => account.meta), + }), + }; } } - -function isTbankUnavailable(error: unknown): boolean { - if (!(error instanceof ServiceUnavailableException)) return false; - const response = error.getResponse(); - return ( - typeof response === 'object' && - response !== null && - 'code' in response && - response.code === 'TBANK_UNAVAILABLE' - ); -} diff --git a/src/modules/investments/income/income-analytics.controller.ts b/src/modules/investments/income/income-analytics.controller.ts index 41c7af2..5d08d42 100644 --- a/src/modules/investments/income/income-analytics.controller.ts +++ b/src/modules/investments/income/income-analytics.controller.ts @@ -4,10 +4,11 @@ import type { Request } from 'express'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { - IncomeAnalyticsDto, + IncomeAnalyticsResourceDto, IncomeAnalyticsQueryDto, - IncomeSummaryDto, + IncomeSummaryResourceDto, } from './income-analytics.dto'; +import { InvestmentReadQueryDto } from '../investment-resource.dto'; import { IncomeAnalyticsService } from './income-analytics.service'; @ApiTags('brokerage-accounts') @@ -19,31 +20,49 @@ export class IncomeAnalyticsController { constructor(private readonly income: IncomeAnalyticsService) {} @Get('all/income-summary') - @ApiOkResponse({ type: IncomeSummaryDto }) - allSummary(@Req() request: Request & { user: AccessPayload }) { - return this.income.summary(request.user.sub, 'all'); + @ApiOkResponse({ type: IncomeSummaryResourceDto }) + allSummary( + @Req() request: Request & { user: AccessPayload }, + @Query() query: InvestmentReadQueryDto, + ) { + return this.income.summary( + request.user.sub, + 'all', + query.sync === 'observe', + ); } @Get('all/income-analytics') - @ApiOkResponse({ type: IncomeAnalyticsDto }) + @ApiOkResponse({ type: IncomeAnalyticsResourceDto }) allAnalytics( @Req() request: Request & { user: AccessPayload }, @Query() query: IncomeAnalyticsQueryDto, ) { - return this.income.analytics(request.user.sub, 'all', query.from, query.to); + return this.income.analytics( + request.user.sub, + 'all', + query.from, + query.to, + query.sync === 'observe', + ); } @Get(':accountId/income-summary') - @ApiOkResponse({ type: IncomeSummaryDto }) + @ApiOkResponse({ type: IncomeSummaryResourceDto }) summary( @Req() request: Request & { user: AccessPayload }, @Param('accountId') accountId: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.income.summary(request.user.sub, accountId); + return this.income.summary( + request.user.sub, + accountId, + query.sync === 'observe', + ); } @Get(':accountId/income-analytics') - @ApiOkResponse({ type: IncomeAnalyticsDto }) + @ApiOkResponse({ type: IncomeAnalyticsResourceDto }) analytics( @Req() request: Request & { user: AccessPayload }, @Param('accountId') accountId: string, @@ -54,6 +73,7 @@ export class IncomeAnalyticsController { accountId, query.from, query.to, + query.sync === 'observe', ); } } diff --git a/src/modules/investments/income/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts index 23b95c1..7e0ab99 100644 --- a/src/modules/investments/income/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -3,9 +3,14 @@ import { IsOptional, Matches } from 'class-validator'; import { MoneyDto } from '../../../common/money.dto'; import { BrokerageOperationDto } from '../operations/operations.api'; +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../investment-resource.dto'; + const dateOnly = /^\d{4}-\d{2}-\d{2}$/; -export class IncomeAnalyticsQueryDto { +export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto { @ApiPropertyOptional({ format: 'date' }) @IsOptional() @Matches(dateOnly) @@ -121,8 +126,8 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto { } export class IncomeAnalyticsDto extends IncomeSummaryDto { - @ApiProperty({ type: IncomeComparisonDto }) - declare previous: IncomeComparisonDto; + @ApiProperty({ type: IncomeComparisonDto, nullable: true }) + declare previous: IncomeComparisonDto | null; @ApiProperty({ type: IncomeComparisonDto, nullable: true }) declare yearComparison: IncomeComparisonDto | null; @@ -139,3 +144,17 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto { @ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true }) declare entries: IncomeAnalyticsEntryDto[]; } + +export class IncomeSummaryResourceDto { + @ApiProperty({ type: IncomeSummaryDto, nullable: true }) + declare data: IncomeSummaryDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class IncomeAnalyticsResourceDto { + @ApiProperty({ type: IncomeAnalyticsDto, nullable: true }) + declare data: IncomeAnalyticsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} diff --git a/src/modules/investments/income/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts index a0af087..56f11fc 100644 --- a/src/modules/investments/income/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -1,8 +1,13 @@ +jest.mock('../../../prisma/prisma.service', () => ({ + PrismaService: class {}, +})); jest.mock('../operations/application/brokerage-operations.service', () => ({ BrokerageOperationsService: class BrokerageOperationsService {}, })); +import assert from 'node:assert/strict'; import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto'; +import { investmentResourceMeta } from '../investment-resource.mapper'; import { IncomeAnalyticsService } from './income-analytics.service'; describe('IncomeAnalyticsService', () => { @@ -19,7 +24,10 @@ describe('IncomeAnalyticsService', () => { build: (opened?: Date) => unknown[], ) => { build(undefined); - return { pages: [[{ items }], [{ items: [] }], [{ items: [] }]] }; + return [items, [], []].map((items, index) => ({ + items, + meta: completeMeta(index), + })); }, ); } @@ -57,13 +65,15 @@ describe('IncomeAnalyticsService', () => { }; seed([payment]); - const result = await service.analytics( + const response = await service.analytics( 'user', 'account', '2026-01-01', '2026-01-31', ); + const result = response.data; + assert(result); expect(result.entries).toEqual([{ ...payment, instrumentKey: key }]); expect(result.instruments).toEqual([ expect.objectContaining({ @@ -92,13 +102,15 @@ describe('IncomeAnalyticsService', () => { ]; seed(selected); - const result = await service.analytics( + const response = await service.analytics( 'user', 'account', '2026-01-01', '2026-01-31', ); + const result = response.data; + assert(result); expect(result.entries).toEqual( selected.map((item) => ({ ...item, @@ -146,37 +158,28 @@ describe('IncomeAnalyticsService', () => { build: (opened?: Date) => unknown[], ) => { build(undefined); - return { - pages: [ - [ - { - items: [ - entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), - entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'), - entry( - 'transfer', - 'OPERATION_TYPE_DIVIDEND_TRANSFER', - '99', - 'RUB', - ), - entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'), - ], - }, - ], - [{ items: [] }], - [{ items: [] }], + return [ + [ + entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), + entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'), + entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'), + entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'), ], - }; + [], + [], + ].map((items, index) => ({ items, meta: completeMeta(index) })); }, ); - const result = await service.analytics( + const response = await service.analytics( 'user', 'account', '2026-01-01', '2026-01-31', ); + const result = response.data; + assert(result); expect(result.totalsByCurrency).toEqual([ expect.objectContaining({ currency: 'RUB', @@ -222,3 +225,14 @@ function entry( commission: null, }; } + +function completeMeta(index: number) { + return investmentResourceMeta({ + resourceKey: `period/${index}`, + dataRevision: '1', + availability: 'complete', + fetchedAt: new Date('2026-02-01Z'), + freshForMs: Infinity, + work: null, + }); +} diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index c59f4d9..da650b8 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { Injectable } from '@nestjs/common'; import { add, @@ -6,6 +7,11 @@ import { formatDecimal, subtract, } from '../../../common/decimal'; +import { investmentResourceMeta } from '../investment-resource.mapper'; +import { + IncomeAnalyticsResourceDto, + IncomeSummaryResourceDto, +} from './income-analytics.dto'; import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { BrokerageOperationDto, @@ -13,7 +19,6 @@ import { } from '../operations/operations.api'; import { - IncomeAnalyticsDto, IncomeAnalyticsEntryDto, IncomeComparisonDto, IncomeCurrencySummaryDto, @@ -35,24 +40,32 @@ type Total = { export class IncomeAnalyticsService { constructor(private readonly operations: BrokerageOperationsService) {} - async summary(userId: string, accountId: string): Promise { - let plan: ReturnType | undefined; - const collected = await this.operations.collectRanges( + async summary( + userId: string, + accountId: string, + observe = false, + ): Promise { + let plan = planIncomePeriod(undefined, undefined); + const [selected] = await this.operations.collectRanges( userId, accountId, (openedAt) => { - const planned = planIncomePeriod(undefined, undefined, openedAt); - plan = planned; - return [planned.selected]; + plan = planIncomePeriod(undefined, undefined, openedAt); + return [plan.selected]; }, + observe, ); - if (!plan) throw new Error('Income period was not planned'); - return summaryResponse( - accountId, - plan.selected, - qualifying(collected.pages[0].flatMap((page) => page.items)), - new Date().toISOString(), - ); + if (selected.items === null) return { data: null, meta: selected.meta }; + assert(selected.meta.lastSuccessfulAt); + return { + data: summaryResponse( + accountId, + plan.selected, + qualifying(selected.items), + selected.meta.lastSuccessfulAt, + ), + meta: selected.meta, + }; } async analytics( @@ -60,52 +73,80 @@ export class IncomeAnalyticsService { accountId: string, from: string | undefined, to: string | undefined, - ): Promise { - let plan: ReturnType | undefined; - const collected = await this.operations.collectRanges( + observe = false, + ): Promise { + let plan = planIncomePeriod(from, to); + const periods = await this.operations.collectRanges( userId, accountId, (openedAt) => { - const planned = planIncomePeriod(from, to, openedAt); - plan = planned; + plan = planIncomePeriod(from, to, openedAt); return [ - planned.selected, - planned.previous, - ...(planned.yearAgo ? [planned.yearAgo] : []), + plan.selected, + plan.previous, + ...(plan.yearAgo ? [plan.yearAgo] : []), ]; }, + observe, ); - if (!plan) throw new Error('Income period was not planned'); - const selected = qualifying( - collected.pages[0].flatMap((page) => page.items), - ).map((entry) => ({ + const [current, prior, year] = periods; + const dependencies = periods.map((period) => period.meta); + if (!plan.yearAgo) + dependencies.push({ + ...investmentResourceMeta({ + resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier + dataRevision: 'unavailable', + availability: 'missing', + fetchedAt: null, + freshForMs: 0, + work: null, + unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', + }), + }); + const states = periods.map((period) => period.meta.sync); + const work = + states.find((state) => state.state === 'running') ?? + states.find((state) => state.state === 'failed'); + const meta = investmentResourceMeta({ + resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, + dataRevision: current.meta.dataRevision, + availability: current.meta.availability, + fetchedAt: current.meta.lastSuccessfulAt + ? new Date(current.meta.lastSuccessfulAt) + : null, + freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0, + work: work?.id ? { ...work, id: work.id } : null, + coverage: current.meta.coverage, + dependencies, + }); + if (current.items === null) return { data: null, meta }; + assert(current.meta.lastSuccessfulAt); + const selected = qualifying(current.items).map((entry) => ({ ...entry, instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`, })); - const previous = qualifying( - collected.pages[1].flatMap((page) => page.items), - ); - const yearAgo = plan.yearAgo - ? qualifying(collected.pages[2].flatMap((page) => page.items)) - : null; - const calculatedAt = new Date().toISOString(); - const base = summaryResponse( - accountId, - plan.selected, - selected, - calculatedAt, - ); return { - ...base, - previous: comparison(selected, plan.previous, previous), - yearComparison: - plan.yearAgo && yearAgo - ? comparison(selected, plan.yearAgo, yearAgo) - : null, - bucketGranularity: plan.granularity, - buckets: buckets(plan.selected, plan.granularity, selected), - instruments: instruments(selected), - entries: selected, + data: { + ...summaryResponse( + accountId, + plan.selected, + selected, + current.meta.lastSuccessfulAt, + ), + previous: + prior.items === null + ? null + : comparison(selected, plan.previous, qualifying(prior.items)), + yearComparison: + plan.yearAgo && year?.items !== null && year + ? comparison(selected, plan.yearAgo, qualifying(year.items)) + : null, + bucketGranularity: plan.granularity, + buckets: buckets(plan.selected, plan.granularity, selected), + instruments: instruments(selected), + entries: selected, + }, + meta, }; } } diff --git a/src/modules/investments/income/income-read.api.ts b/src/modules/investments/income/income-read.api.ts new file mode 100644 index 0000000..e820d2f --- /dev/null +++ b/src/modules/investments/income/income-read.api.ts @@ -0,0 +1 @@ +export { IncomeAnalyticsService } from './income-analytics.service'; diff --git a/src/modules/investments/income/income.api.ts b/src/modules/investments/income/income.api.ts new file mode 100644 index 0000000..a00192f --- /dev/null +++ b/src/modules/investments/income/income.api.ts @@ -0,0 +1 @@ +export { planIncomePeriod } from './income-period'; diff --git a/src/modules/investments/income/income.module.ts b/src/modules/investments/income/income.module.ts index fbe6d07..3185170 100644 --- a/src/modules/investments/income/income.module.ts +++ b/src/modules/investments/income/income.module.ts @@ -8,5 +8,6 @@ import { IncomeAnalyticsService } from './income-analytics.service'; imports: [AuthModule, OperationsModule], controllers: [IncomeAnalyticsController], providers: [IncomeAnalyticsService], + exports: [IncomeAnalyticsService], }) export class IncomeModule {} diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts new file mode 100644 index 0000000..f6830e6 --- /dev/null +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -0,0 +1,481 @@ +import { planInvestmentScreen } from '../../investment-sync-plan'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { MILLISECONDS_PER_DAY } from '../../../../common/time'; +import { formatDecimal } from '../../../../common/decimal'; +import { decimal } from '../../../../integrations/tbank/decimal'; +import { + TbankAccessService, + TbankClient, + type TbankShare, +} from '../../../../integrations/tbank/tbank.api'; +import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { CatalogEnrichmentService } from './catalog-enrichment.service'; +import { CandleRepository } from '../infrastructure/candle.repository'; +import { + candleGaps, + candleWindows, + utcDay, + type CandleRange, +} from '../domain/candle-coverage'; +import { timestamp } from './instrument-market'; + +type CandleInput = { + uid: string; + instrumentType: 'share' | 'bond'; + period: 'month' | 'halfYear' | 'year' | 'all'; + range?: CandleRange; +}; +type Producer = { + input: CandleInput; + attempt: TbankSyncAttempt; + now: Date; + force: boolean; + priority: number; + observers: TbankSyncAttempt[]; + ranges?: CandleRange[]; + nextWindow?: number; +}; +const months = { month: 1, halfYear: 6, year: 12 }; +const tailFreshForMs = 60_000; +const prefetchReadBatchSize = 100; +function scope(input: CandleInput) { + return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`; +} +function rangeFor( + input: CandleInput, + instrument: TbankShare, + now: Date, +): CandleRange { + if (input.range) return input.range; + const from = utcDay(now); + const history = timestamp(instrument.first_1dayCandleDate); + if (input.period === 'all') { + if (!history) + throw new ServiceUnavailableException({ + code: + input.instrumentType === 'share' + ? 'STOCK_HISTORY_START_UNAVAILABLE' + : 'BOND_HISTORY_START_UNAVAILABLE', + }); + from.setTime(utcDay(new Date(history)).valueOf()); + } else { + from.setUTCMonth(from.getUTCMonth() - months[input.period]); + if (history && Date.parse(history) > from.valueOf()) + from.setTime(utcDay(new Date(history)).valueOf()); + } + return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) }; +} +@Injectable() +export class CandleService { + private readonly runId = randomUUID(); + private readonly active = new Set(); + private readonly producers = new Map(); + private readonly queue: Producer[] = []; + private reservations: Promise = Promise.resolve(); + private running = false; + private readonly prefetches = new Map>(); + constructor( + private readonly repository: CandleRepository, + private readonly sources: CatalogEnrichmentService, + private readonly access: TbankAccessService, + private readonly tbank: TbankClient, + private readonly logger: StructuredLogger, + ) {} + + async read(userId: string, input: CandleInput, observe = false) { + const { revision } = await this.access.local(userId); + const [detail] = await this.sources.readMany( + userId, + input.instrumentType, + [input.uid], + true, + ['detail'], + ); + if (detail.meta.availability === 'complete' && !detail.instrument) + throw new NotFoundException(); + const now = new Date(); + const range = detail.instrument + ? rangeFor(input, detail.instrument, now) + : null; + const saved = range ? await this.repository.read(input.uid, range) : null; + const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; + if (!observe && (!saved || gaps.length)) + await this.request(userId, revision, input, now); + const work = await this.repository.work(userId, revision, scope(input)); + const fetchedAt = + saved?.coverage + .map((row) => row.fetchedAt) + .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? null; + const covered = saved?.coverage.length ? true : false; + const completeCoverage = (saved?.coverage ?? []) + .filter((row) => row.isComplete || row.from >= utcDay(now)) + .map((row) => ({ ...row, isComplete: true })); + const missing = range ? candleGaps(range, completeCoverage, now) : []; + const complete = Boolean(saved && range && !missing.length); + const publicRange = (value: CandleRange) => ({ + from: value.from.toISOString(), + to: value.to.toISOString(), + }); + return { + data: + !saved || !covered + ? null + : { + candles: saved.data, + currency: + input.instrumentType === 'share' + ? detail.instrument?.currency?.toUpperCase() || null + : null, + historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate), + observedAt: fetchedAt?.toISOString() ?? now.toISOString(), + }, + meta: investmentResourceMeta({ + resourceKey: scope(input), + dataRevision: saved?.dataRevision ?? '0', + availability: !covered ? 'missing' : complete ? 'complete' : 'partial', + fetchedAt, + freshForMs: Number.POSITIVE_INFINITY, + work, + coverage: { + requested: range ? [publicRange(range)] : [], + completeRanges: range + ? completeCoverage.flatMap((row) => { + const from = new Date( + Math.max(row.from.valueOf(), range.from.valueOf()), + ); + const to = new Date( + Math.min(row.to.valueOf(), range.to.valueOf()), + ); + return from < to ? [publicRange({ from, to })] : []; + }) + : [], + missingRanges: missing.map(publicRange), + asOf: fetchedAt?.toISOString() ?? null, + historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) + ? 'known' + : 'unknown', + historyState: complete + ? 'complete' + : work?.state === 'failed' + ? 'failed' + : work?.state === 'running' + ? 'loading' + : covered + ? 'partial' + : 'missing', + }, + dependencies: [ + detail.meta, + ...(saved?.coverage ?? []).map((row) => + investmentResourceMeta({ + resourceKey: `${scope(input)}/${row.from.toISOString()}/${row.to.toISOString()}`, + dataRevision: String(row.dataRevision), + availability: 'complete', + fetchedAt: row.fetchedAt, + freshForMs: row.isComplete + ? Number.POSITIVE_INFINITY + : tailFreshForMs, + work: null, + }), + ), + ], + }), + }; + } + + prefetch( + userId: string, + revision: string, + positions: { instrumentUid?: string | null; type: string }[], + ) { + const key = JSON.stringify([ + userId, + revision, + positions + .map((position) => [position.instrumentUid, position.type]) + .sort(), + ]); + const existing = this.prefetches.get(key); + if (existing) return existing; + const work = this.prefetchPositions(userId, revision, positions).finally( + () => this.prefetches.delete(key), + ); + this.prefetches.set(key, work); + return work; + } + + private async prefetchPositions( + userId: string, + revision: string, + positions: { instrumentUid?: string | null; type: string }[], + ) { + const now = new Date(); + const unique = new Map( + positions + .filter( + (position) => + position.instrumentUid && ['STOCK', 'BOND'].includes(position.type), + ) + .map((position) => [position.instrumentUid ?? '', position]), + ); + const details: Awaited> = + []; + const coverage: Awaited> = []; + for (const instrumentType of ['share', 'bond'] as const) { + const uids = [...unique] + .filter( + ([, position]) => + position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'), + ) + .map(([uid]) => uid); + for ( + let offset = 0; + offset < uids.length; + offset += prefetchReadBatchSize + ) { + const batch = uids.slice(offset, offset + prefetchReadBatchSize); + details.push( + ...(await this.sources.readMany(userId, instrumentType, batch, true, [ + 'detail', + ])), + ); + coverage.push(...(await this.repository.coverage(batch))); + } + } + const byUid = new Map(details.map((detail) => [detail.input.uid, detail])); + for (const [uid, position] of unique) { + const instrumentType: CandleInput['instrumentType'] = + position.type === 'STOCK' ? 'share' : 'bond'; + const detail = byUid.get(uid); + const history = timestamp(detail?.instrument?.first_1dayCandleDate); + const plan = planInvestmentScreen( + { screen: 'account' }, + { + membershipKnown: true, + accounts: [], + instruments: [ + { + uid, + type: position.type as 'STOCK' | 'BOND', + historyFrom: history ? new Date(history) : null, + }, + ], + }, + now, + ); + if ( + !detail?.instrument || + plan.sources.some((source) => source.kind === 'candles') + ) { + const input = { uid, instrumentType, period: 'all' as const }; + const range = + detail?.instrument && history + ? rangeFor(input, detail.instrument, now) + : null; + if ( + !range || + candleGaps( + range, + coverage.filter((row) => row.instrumentUid === uid), + now, + ).length + ) + await this.request(userId, revision, input, now, false, 2); + } + } + } + + request( + userId: string, + revision: string, + input: CandleInput, + now = new Date(), + force = false, + priority = 1, + ) { + const pending = this.reservations.then(async () => { + const reserved = await this.repository.reserve( + { userId, revision }, + scope(input), + { + uid: input.uid, + instrumentType: input.instrumentType, + ranges: [], + period: input.period, + now: now.toISOString(), + force, + }, + this.runId, + this.active, + priority, + ); + if (!reserved) return null; + const attempt = { + userId, + revision, + id: reserved.work.id, + attemptId: reserved.work.attemptId, + }; + if (reserved.launch) { + this.active.add(attempt.attemptId); + const producer = this.producers.get(scope(input)); + if (producer) producer.observers.push(attempt); + else { + const next = { input, attempt, now, force, priority, observers: [] }; + this.producers.set(scope(input), next); + this.queue.push(next); + } + if (!this.running) { + this.running = true; + setImmediate(() => { + void this.drain(); + }); + } + } + return attempt.id; + }); + this.reservations = pending.catch(() => undefined); + return pending; + } + + private async load(producer: Producer) { + const { input, attempt, now } = producer; + const instrument = await this.sources.ensureDetail( + attempt.userId, + attempt.revision, + input.instrumentType, + input.uid, + ); + if (!instrument) throw new NotFoundException(); + const range = rangeFor(input, instrument, now); + if (!producer.ranges) { + const saved = await this.repository.read(input.uid, range); + producer.ranges = ( + producer.force ? [range] : candleGaps(range, saved.coverage, now) + ) + .flatMap(candleWindows) + .reverse(); + producer.nextWindow = 0; + } + const ranges = producer.ranges; + const index = producer.nextWindow ?? 0; + let window = ranges[index]; + if (window && !producer.force) { + const latest = await this.repository.read(input.uid, window); + const gaps = candleGaps(window, latest.coverage, now) + .flatMap(candleWindows) + .reverse(); + ranges.splice(index, 1, ...gaps); + window = ranges[index]; + } + if (window) { + const token = await this.access.tokenForAttempt(attempt); + if (!token) return false; + const candles = await this.tbank.getCandles( + token, + input.uid, + window.from, + new Date(Math.min(window.to.valueOf(), now.valueOf())), + ); + const rows = candles.flatMap((candle) => { + const time = timestamp(candle.time); + if (!time || !candle.close) throw new Error('Invalid candle response'); + return [ + { + time, + close: formatDecimal(decimal(candle.close)), + isComplete: candle.isComplete === true, + }, + ]; + }); + if (!(await this.repository.stage(attempt, window, rows, now))) + return false; + if ( + !producer.force && + !(await this.repository.publish( + attempt, + { uid: input.uid, ranges: [window] }, + false, + )) + ) + return false; + producer.nextWindow = index + 1; + if (producer.nextWindow < ranges.length) return null; + } + return this.repository.publish(attempt, { + uid: input.uid, + ranges: producer.force ? ranges : [], + }); + } + + private async drain() { + try { + while (this.queue.length) { + this.queue.sort((a, b) => a.priority - b.priority); + const producer = this.queue.shift(); + if (!producer) break; + let success = false; + let code = 'TBANK_UNAVAILABLE'; + try { + const result = await this.load(producer); + if (result === null) { + this.queue.push(producer); + continue; + } + success = result; + } catch (error) { + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + await this.access.rejectMembershipToken(producer.attempt); + if (error instanceof ServiceUnavailableException) { + const response = error.getResponse(); + if ( + typeof response === 'object' && + 'code' in response && + typeof response.code === 'string' + ) + code = response.code; + } + } + if (!success) await this.repository.fail(producer.attempt, code); + this.active.delete(producer.attempt.attemptId); + for (const observer of producer.observers) { + if ( + !success || + !(await this.repository.completeObserver( + observer, + producer.attempt, + )) + ) + await this.repository.fail(observer, code); + this.active.delete(observer.attemptId); + } + this.producers.delete(scope(producer.input)); + if (success && !producer.force && producer.input.period !== 'all') + await this.request( + producer.attempt.userId, + producer.attempt.revision, + { ...producer.input, period: 'all' }, + producer.now, + false, + 2, + ); + } + } catch { + this.logger.event('error', 'candles.persistence-failed', { + code: 'TBANK_UNAVAILABLE', + }); + } finally { + this.active.clear(); + this.producers.clear(); + this.queue.length = 0; + this.running = false; + } + } +} diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts new file mode 100644 index 0000000..1905550 --- /dev/null +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -0,0 +1,390 @@ +import { CatalogEnrichmentService } from './catalog-enrichment.service'; +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { + TbankAccessService, + TbankClient, + tbankSourceNeedsRefresh, + tbankSourceTtlMs, +} from '../../../../integrations/tbank/tbank.api'; +import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { + CatalogDiscoveryRepository, + catalogQuery, + catalogScope, + type CatalogQuery, +} from '../infrastructure/catalog-discovery.repository'; +import { + catalogSearchLimit, + selectCatalogInstruments, +} from './catalog-selection'; + +const tickerAmbiguityLimit = 2; + +type Producer = { + input: CatalogQuery; + attempt: TbankSyncAttempt; + observers: TbankSyncAttempt[]; + force: boolean; +}; +@Injectable() +export class CatalogDiscoveryService { + private readonly runId = randomUUID(); + private readonly active = new Set(); + private readonly producers = new Map(); + private readonly queue: Producer[] = []; + private reservations: Promise = Promise.resolve(); + private running = false; + private readonly completions = new Map< + string, + { workId: string; done: Promise; resolve: () => void } + >(); + constructor( + private readonly repository: CatalogDiscoveryRepository, + private readonly access: TbankAccessService, + private readonly tbank: TbankClient, + private readonly logger: StructuredLogger, + private readonly enrichment: CatalogEnrichmentService, + ) {} + + async read(userId: string, input: CatalogQuery, observe = false) { + input = catalogQuery(input); + const connection = await this.access.local(userId); + const saved = await this.repository.read(input); + if (!observe) await this.request(userId, connection.revision, input); + return { + data: saved.data, + meta: investmentResourceMeta({ + resourceKey: catalogScope(input), + dataRevision: saved.dataRevision, + availability: saved.data === null ? 'missing' : 'complete', + fetchedAt: saved.fetchedAt, + freshForMs: tbankSourceTtlMs.catalog, + work: await this.repository.work(userId, connection.revision, input), + }), + }; + } + + async search(userId: string, input: CatalogQuery, observe = false) { + const discovery = await this.read(userId, input, observe); + const sources = discovery.data + ? await this.enrichment.readMany( + userId, + input.instrumentType, + discovery.data.map((item) => item.uid), + observe, + ) + : []; + const dependencies = [ + discovery.meta, + ...sources.map((source) => source.meta), + ]; + return { + data: + discovery.data?.map((short) => ({ + ...short, + detail: + sources.find( + (source) => + source.input.uid === short.uid && + source.input.kind === 'detail', + )?.instrument ?? null, + price: + sources.find( + (source) => + source.input.uid === short.uid && + source.input.kind === 'last-price', + )?.price ?? null, + })) ?? null, + meta: investmentResourceMeta({ + resourceKey: `search:${catalogScope(catalogQuery(input))}`, + dataRevision: dependencies + .map((dependency) => dependency.dataRevision) + .join('/'), + availability: + discovery.data === null + ? 'missing' + : sources.every((source) => source.meta.availability === 'complete') + ? 'complete' + : 'partial', + fetchedAt: discovery.meta.lastSuccessfulAt + ? new Date(discovery.meta.lastSuccessfulAt) + : null, + freshForMs: tbankSourceTtlMs.catalog, + work: discovery.meta.sync.id + ? { + id: discovery.meta.sync.id, + state: discovery.meta.sync.state, + errorCode: discovery.meta.sync.errorCode, + } + : null, + dependencies, + }), + }; + } + + async detail( + userId: string, + instrumentType: 'share' | 'bond', + uid: string, + observe = false, + ) { + const sources = await this.enrichment.readMany( + userId, + instrumentType, + [uid], + observe, + ['detail'], + ); + const source = sources[0]; + if (source.meta.availability === 'complete' && !source.instrument) + throw new NotFoundException(); + const instrument = source.instrument; + let slug = uid; + const dependencies = [source.meta]; + if ( + instrument?.ticker && + /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker) + ) { + const alias = await this.read( + userId, + { instrumentType, query: instrument.ticker, exactTicker: true }, + observe, + ); + if (alias.data?.length === 1 && alias.data[0].uid === uid) + slug = instrument.ticker.toUpperCase(); + dependencies.push(alias.meta); + } + return { + data: instrument ? { ...instrument, uid, slug } : null, + meta: investmentResourceMeta({ + resourceKey: `${instrumentType}/detail/${uid}`, + dataRevision: dependencies + .map((dependency) => dependency.dataRevision) + .join('/'), + availability: source.meta.availability, + fetchedAt: source.meta.lastSuccessfulAt + ? new Date(source.meta.lastSuccessfulAt) + : null, + freshForMs: tbankSourceTtlMs.metadata, + work: source.meta.sync.id + ? { + id: source.meta.sync.id, + state: source.meta.sync.state, + errorCode: source.meta.sync.errorCode, + } + : null, + dependencies, + }), + }; + } + + async byTicker( + userId: string, + instrumentType: 'share' | 'bond', + ticker: string, + observe = false, + ) { + const lookup = await this.read( + userId, + { instrumentType, query: ticker, exactTicker: true }, + observe, + ); + if (lookup.data?.length === 0) throw new NotFoundException(); + if (lookup.data && lookup.data.length > 1) + throw new ConflictException({ + code: + instrumentType === 'share' + ? 'STOCK_TICKER_AMBIGUOUS' + : 'BOND_TICKER_AMBIGUOUS', + }); + const result = lookup.data + ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) + : null; + const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; + return { + data: result?.data + ? { ...result.data, slug: ticker.trim().toUpperCase() } + : null, + meta: investmentResourceMeta({ + resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, + dataRevision: dependencies + .map((dependency) => dependency.dataRevision) + .join('/'), + availability: result?.meta.availability ?? 'missing', + fetchedAt: lookup.meta.lastSuccessfulAt + ? new Date(lookup.meta.lastSuccessfulAt) + : null, + freshForMs: tbankSourceTtlMs.metadata, + work: lookup.meta.sync.id + ? { + id: lookup.meta.sync.id, + state: lookup.meta.sync.state, + errorCode: lookup.meta.sync.errorCode, + } + : null, + dependencies, + }), + }; + } + + request( + userId: string, + revision: string, + query: CatalogQuery, + force = false, + ) { + const input = catalogQuery(query); + const reserved = this.reservations.then(async () => { + const saved = await this.repository.read(input); + if ( + !tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force) + ) + return null; + const reservation = await this.repository.reserve( + { userId, revision }, + input, + this.runId, + this.active, + ); + if (!reservation) return null; + const { id, attemptId } = reservation.work; + if (!reservation.launch) { + const current = this.producers.get(catalogScope(input)); + if (current) current.force ||= force; + return id; + } + const attempt = { userId, revision, id, attemptId }; + this.active.add(attemptId); + let resolve = () => {}; + const done = new Promise((finish) => { + resolve = finish; + }); + this.completions.set(attemptId, { workId: id, done, resolve }); + const key = catalogScope(input); + const producer = this.producers.get(key); + if (producer) { + producer.observers.push(attempt); + producer.force ||= force; + } else { + const queued = { input, attempt, observers: [], force }; + this.producers.set(key, queued); + this.queue.push(queued); + } + if (!this.running) { + this.running = true; + setImmediate(() => { + void this.drain(); + }); + } + return id; + }); + this.reservations = reserved.catch(() => undefined); + return reserved; + } + + async refresh(userId: string, revision: string, query: CatalogQuery) { + const id = await this.request(userId, revision, query, true); + if (id) + await Promise.all( + [...this.completions.values()] + .filter((entry) => entry.workId === id) + .map((entry) => entry.done), + ); + const saved = await this.repository.read(catalogQuery(query)); + const sources = await this.enrichment.readMany( + userId, + query.instrumentType, + saved.data?.map((item) => item.uid) ?? [], + true, + ); + return [ + ...(id ? [id] : []), + ...sources.flatMap((source) => + source.meta.sync.id ? [source.meta.sync.id] : [], + ), + ]; + } + + private async drain() { + try { + while (this.queue.length) { + const producer = this.queue.shift(); + if (!producer) break; + const { input, attempt } = producer; + let success = false; + try { + const token = await this.access.tokenForAttempt(attempt); + if (token) { + const found = + input.instrumentType === 'share' + ? await this.tbank.findShares(token, input.query) + : await this.tbank.findBonds(token, input.query); + success = await this.repository.publish( + attempt, + input, + selectCatalogInstruments( + input.exactTicker + ? found.filter( + (item) => + item.ticker?.trim().toUpperCase() === input.query, + ) + : found, + input.query, + input.instrumentType, + ).slice( + 0, + input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit, + ), + new Date(), + ); + } + } catch (error) { + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + await this.access.rejectMembershipToken(attempt); + else + this.logger.event('warn', 'catalog-discovery.failed', { + code: 'TBANK_UNAVAILABLE', + }); + } + if (success) { + const saved = await this.repository.read(input); + for (const observer of [attempt, ...producer.observers]) + if (!input.exactTicker || saved.data?.length === 1) + await this.enrichment.requestMany( + observer.userId, + observer.revision, + input.instrumentType, + saved.data?.map((item) => item.uid) ?? [], + producer.force, + input.exactTicker ? ['detail'] : ['last-price', 'detail'], + ); + } + this.producers.delete(catalogScope(input)); + for (const observer of [attempt, ...producer.observers]) { + await this.repository.finish(observer, success); + this.active.delete(observer.attemptId); + this.completions.get(observer.attemptId)?.resolve(); + this.completions.delete(observer.attemptId); + } + } + } catch { + for (const completion of this.completions.values()) completion.resolve(); + this.completions.clear(); + this.active.clear(); + this.producers.clear(); + this.queue.length = 0; + this.logger.event('error', 'catalog-discovery.persistence-failed', { + code: 'TBANK_UNAVAILABLE', + }); + } finally { + this.running = false; + } + } +} diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts new file mode 100644 index 0000000..785347c --- /dev/null +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -0,0 +1,665 @@ +import type { TbankDividend } from '../../../../integrations/tbank/tbank.api'; +import type { MoexBondEvent } from '../../../../integrations/moex/moex.api'; +import { CandleRepository } from '../infrastructure/candle.repository'; +import { candleGaps, utcDay } from '../domain/candle-coverage'; +import { + decimalFromString, + divide, + formatDecimal, +} from '../../../../common/decimal'; +import { decimal } from '../../../../integrations/tbank/decimal'; +import { + MILLISECONDS_PER_DAY, + MILLISECONDS_PER_SECOND, +} from '../../../../common/time'; +import { MoexClient } from '../../../../integrations/moex/moex.api'; +import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; +import type { + TbankCandle, + TbankFundamentals, +} from '../../../../integrations/tbank/tbank.api'; +import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types'; +import { previousClose, timestamp } from './instrument-market'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { + TbankAccessService, + TbankClient, + tbankSourceNeedsRefresh, + tbankSourceTtlMs, + type TbankBond, + type TbankShare, +} from '../../../../integrations/tbank/tbank.api'; +import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types'; +import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { + CatalogDiscoveryRepository, + catalogScope, + type CatalogSource, +} from '../infrastructure/catalog-discovery.repository'; +import { catalogSearchLimit } from './catalog-selection'; + +type Producer = { + input: CatalogSource; + attempt: TbankSyncAttempt; + observers: { attempt: TbankSyncAttempt; input: CatalogSource }[]; +}; +@Injectable() +export class CatalogEnrichmentService { + private readonly runId = randomUUID(); + private readonly active = new Set(); + private readonly producers = new Map(); + private readonly queue: Producer[] = []; + private reservations: Promise = Promise.resolve(); + private running = false; + private readonly completions = new Map< + string, + { workId: string; done: Promise; resolve: () => void } + >(); + + async ensureDetail( + userId: string, + revision: string, + instrumentType: 'share' | 'bond', + uid: string, + ) { + const ids = await this.requestMany( + userId, + revision, + instrumentType, + [uid], + false, + ['detail'], + ); + await Promise.all( + [...this.completions.values()] + .filter((entry) => ids.includes(entry.workId)) + .map((entry) => entry.done), + ); + const rows = await this.repository.readSources([ + { instrumentType, uid, kind: 'detail' }, + ]); + if (!rows.length) throw new Error('Instrument dependency missing'); + return (rows[0].payload as CatalogPayload).instrument ?? null; + } + constructor( + private readonly repository: CatalogDiscoveryRepository, + private readonly access: TbankAccessService, + private readonly tbank: TbankClient, + private readonly logger: StructuredLogger, + private readonly moex: MoexClient, + private readonly ratings: NcrRatingsClient, + private readonly candles: CandleRepository, + ) {} + + async readMany( + userId: string, + instrumentType: 'share' | 'bond', + uids: string[], + observe: boolean, + kinds: CatalogSource['kind'][] = ['last-price', 'detail'], + range?: { + from: string; + to: string; + ticker?: string; + tickers?: Record; + }, + ) { + const { revision } = await this.access.local(userId); + const inputs = sourceInputs(instrumentType, uids, kinds, range); + const rows = inputs.length ? await this.repository.readSources(inputs) : []; + if (!observe) + await this.requestMany( + userId, + revision, + instrumentType, + uids, + false, + kinds, + range, + ); + const work = inputs.length + ? await this.repository.workMany(userId, revision, inputs) + : []; + const byScope = new Map(work.map((row) => [row.scopeKey, row])); + return inputs.map((input) => { + const row = rows.find( + (entry) => + entry.instrumentUid === input.uid && entry.kind === input.kind, + ); + const payload = row?.payload as CatalogPayload | undefined; + return { + input, + version: row + ? { + instrumentUid: row.instrumentUid, + source: row.source, + kind: row.kind, + rangeKey: row.rangeKey, + dataRevision: row.dataRevision, + fetchedAt: row.fetchedAt, + } + : null, + payload, + instrument: payload?.instrument ?? null, + price: payload?.price ?? null, + meta: investmentResourceMeta({ + resourceKey: catalogScope(input), + dataRevision: String(row?.dataRevision ?? 0), + availability: row ? 'complete' : 'missing', + fetchedAt: row?.fetchedAt ?? null, + freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)], + work: byScope.get(catalogScope(input)) ?? null, + }), + }; + }); + } + + requestMany( + userId: string, + revision: string, + instrumentType: 'share' | 'bond', + uids: string[], + force = false, + kinds: CatalogSource['kind'][] = ['last-price', 'detail'], + range?: { + from: string; + to: string; + ticker?: string; + tickers?: Record; + }, + ) { + const inputs = sourceInputs(instrumentType, uids, kinds, range); + const reservation = this.reservations.then(async () => { + const rows = inputs.length + ? await this.repository.readSources(inputs) + : []; + const ids: string[] = []; + for (const input of inputs) { + const row = rows.find( + (entry) => + entry.instrumentUid === input.uid && entry.kind === input.kind, + ); + if ( + !tbankSourceNeedsRefresh( + sourcePolicy(input.kind), + row?.fetchedAt ?? null, + new Date(), + force, + ) + ) + continue; + const reserved = await this.repository.reserve( + { userId, revision }, + input, + this.runId, + this.active, + ); + if (!reserved) continue; + const { id, attemptId } = reserved.work; + ids.push(id); + if (!reserved.launch) continue; + const attempt = { userId, revision, id, attemptId }; + this.active.add(attemptId); + let resolve = () => {}; + const done = new Promise((finish) => { + resolve = finish; + }); + this.completions.set(attemptId, { workId: id, done, resolve }); + const key = producerKey(input); + const producer = this.producers.get(key); + if (producer) producer.observers.push({ attempt, input }); + else { + const queued = { input, attempt, observers: [] }; + this.producers.set(key, queued); + this.queue.push(queued); + } + } + if (this.queue.length && !this.running) { + this.running = true; + setImmediate(() => { + void this.drain(); + }); + } + return ids; + }); + this.reservations = reservation.catch(() => undefined); + return reservation; + } + + async section( + userId: string, + instrumentType: 'share' | 'bond', + uid: string, + section: 'quote' | 'company' | 'metrics', + observe = false, + ) { + const kinds: CatalogSource['kind'][] = + section === 'company' + ? ['detail', 'description', 'fundamentals'] + : [ + ...(instrumentType === 'bond' + ? ['bond-current' as const] + : ['detail' as const]), + 'last-price', + 'trading-status', + 'comparison', + ...(section === 'metrics' + ? ['coupons' as const, 'yield' as const, 'rating' as const] + : []), + ]; + const sources = await this.readMany( + userId, + instrumentType, + [uid], + observe, + kinds, + ); + const first = sources[0]; + if (first.meta.availability === 'complete' && !first.instrument) + throw new NotFoundException(); + const values = sources.reduce( + (result, source) => ({ ...result, ...source.payload }), + {}, + ); + const dependencies = sources.map((source) => source.meta); + const complete = dependencies.every( + (dependency) => dependency.availability === 'complete', + ); + const meta = investmentResourceMeta({ + resourceKey: `${instrumentType}/${section}/${uid}`, + dataRevision: dependencies + .map((dependency) => dependency.dataRevision) + .join('/'), + availability: !first.instrument + ? 'missing' + : complete + ? 'complete' + : 'partial', + fetchedAt: first.meta.lastSuccessfulAt + ? new Date(first.meta.lastSuccessfulAt) + : null, + freshForMs: + section === 'company' + ? tbankSourceTtlMs.company + : tbankSourceTtlMs.quote, + work: null, + dependencies, + }); + return { + values, + meta, + unavailable: sources + .filter( + (source) => + source.meta.availability !== 'complete' || + source.meta.sync.state === 'failed', + ) + .map((source) => source.input.kind), + observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(), + }; + } + + private async load( + input: CatalogSource, + token: string, + attempt: TbankSyncAttempt, + ): Promise { + if (input.kind === 'detail' || input.kind === 'bond-current') { + const instrument = + input.instrumentType === 'share' + ? await this.tbank.getShare(token, input.uid) + : await this.tbank.getBond(token, input.uid); + if (instrument && instrument.uid !== input.uid) + throw new Error('Catalog UID mismatch'); + if (instrument === undefined) throw new Error('Missing catalog response'); + return { instrument: instrument ? publicInstrument(instrument) : null }; + } + if (input.kind === 'trading-status') + return { + tradingStatus: await this.tbank.getTradingStatus(token, input.uid), + }; + if (input.kind === 'comparison') { + const prices = await this.repository.readSources([ + { ...input, kind: 'last-price', rangeKey: undefined }, + ]); + if (!prices.length) throw new Error('Price dependency missing'); + const price = (prices[0].payload as CatalogPayload).price; + const priceAt = timestamp(price?.time); + if (!priceAt) return { candle: null }; + const now = new Date(); + const range = { + from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)), + to: new Date(priceAt), + }; + const saved = await this.candles.read(input.uid, range); + const gaps = candleGaps(range, saved.coverage, now); + for (const gap of gaps) { + const response = await this.tbank.getCandles( + token, + input.uid, + gap.from, + gap.to, + ); + const rows = response.map((candle) => { + const time = timestamp(candle.time); + if (!time || !candle.close) + throw new Error('Invalid candle response'); + return { + time, + close: formatDecimal(decimal(candle.close)), + isComplete: candle.isComplete === true, + }; + }); + if (!(await this.candles.stage(attempt, gap, rows, now))) + throw new Error('Cancelled candle comparison'); + } + if ( + gaps.length && + !(await this.candles.publish( + attempt, + { uid: input.uid, ranges: gaps }, + false, + )) + ) + throw new Error('Cancelled candle comparison'); + const values = (await this.candles.read(input.uid, range)).data.map( + (row) => { + const value = divide( + decimalFromString(row.close), + decimalFromString('1'), + quotationScale, + ).value; + return { + time: { + seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND), + }, + close: { + units: String(value / quotationUnits), + nano: Number(value % quotationUnits), + }, + isComplete: row.isComplete, + }; + }, + ); + return { candle: previousClose(values, priceAt) }; + } + const details = await this.repository.readSources([ + { ...input, kind: 'detail', rangeKey: undefined }, + ...(input.instrumentType === 'bond' + ? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] + : []), + ]); + const instrument = details + .map((row) => (row.payload as CatalogPayload).instrument) + .find(Boolean); + if ( + !instrument && + !['dividends', 'coupons', 'bond-events'].includes(input.kind) + ) + throw new Error('Instrument dependency missing'); + if (input.kind === 'description') + return { + description: instrument?.assetUid + ? await this.tbank.getAssetDescription(token, instrument?.assetUid) + : null, + }; + if (input.kind === 'fundamentals') + return { + fundamentals: instrument?.assetUid + ? await this.tbank.getFundamentals(token, instrument?.assetUid) + : null, + }; + if (input.kind === 'coupons' || input.kind === 'dividends') { + const year = Number(input.rangeKey); + const [from, to] = input.rangeKey?.startsWith('[') + ? (JSON.parse(input.rangeKey) as [string, string]) + : [ + `${year - payoutHistoryYears}-01-01`, + `${year + payoutFutureYears}-01-01`, + ]; + return input.kind === 'coupons' + ? { + coupons: await this.tbank.getBondCoupons( + token, + input.uid, + from, + to, + ), + } + : { + dividends: await this.tbank.getDividends( + token, + input.uid, + from, + to, + ), + }; + } + if (input.kind === 'bond-events') { + const ticker = instrument?.ticker || input.ticker; + if (!ticker) throw new Error('Bond ticker missing'); + return { bondEvents: await this.moex.bondEvents(ticker) }; + } + if (input.kind === 'yield') + return { + effectiveYield: + instrument?.ticker && instrument.isin && instrument.classCode + ? await this.moex.bondYield( + instrument.ticker, + instrument.isin, + instrument.classCode, + ) + : null, + }; + if (input.kind === 'rating') + return { + creditRating: instrument?.isin + ? await this.ratings.rating(instrument.isin) + : null, + }; + throw new Error('Unsupported catalog source'); + } + + private async drain() { + try { + while (this.queue.length) { + const producer = this.queue.shift(); + if (!producer) break; + const batch = [producer]; + // One batch RPC for queued prices sharing the same authorized producer. + if (producer.input.kind === 'last-price') { + for ( + let i = 0; + i < this.queue.length && batch.length < catalogSearchLimit; + ) { + const next = this.queue[i]; + if ( + next.input.kind === 'last-price' && + next.attempt.userId === producer.attempt.userId && + next.attempt.revision === producer.attempt.revision + ) { + batch.push(next); + this.queue.splice(i, 1); + } else i++; + } + } + let prices: TbankLastPrice[] | undefined; + let payload: CatalogPayload | undefined; + let failed = false; + try { + const token = await this.access.tokenForAttempt(producer.attempt); + if (!token) failed = true; + else if (producer.input.kind === 'last-price') + prices = await this.tbank.getLastPrices( + token, + batch.map((item) => item.input.uid), + ); + else + payload = await this.load(producer.input, token, producer.attempt); + } catch (error) { + failed = true; + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + await this.access.rejectMembershipToken(producer.attempt); + else + this.logger.event('warn', 'catalog-enrichment.failed', { + code: 'TBANK_UNAVAILABLE', + }); + } + for (const item of batch) { + const price = + prices?.find((entry) => entry.instrumentUid === item.input.uid) ?? + null; + const value = + item.input.kind === 'last-price' + ? { + price: price + ? { + instrumentUid: item.input.uid, + price: price.price, + time: price.time, + } + : null, + } + : payload; + // Keep accepting observers while publication awaits SQLite. + item.observers.unshift({ attempt: item.attempt, input: item.input }); + for (const observer of item.observers) { + const success = + !failed && + value !== undefined && + (await this.repository.publishSource( + observer.attempt, + observer.input, + value, + new Date(), + producer.attempt, + )); + await this.repository.finish(observer.attempt, success); + this.active.delete(observer.attempt.attemptId); + this.completions.get(observer.attempt.attemptId)?.resolve(); + this.completions.delete(observer.attempt.attemptId); + } + this.producers.delete(producerKey(item.input)); + } + } + } catch { + for (const completion of this.completions.values()) completion.resolve(); + this.completions.clear(); + this.active.clear(); + this.producers.clear(); + this.queue.length = 0; + this.logger.event('error', 'catalog-enrichment.persistence-failed', { + code: 'TBANK_UNAVAILABLE', + }); + } finally { + this.running = false; + } + } +} +function sourceInputs( + instrumentType: 'share' | 'bond', + uids: string[], + kinds: CatalogSource['kind'][], + range?: { + from: string; + to: string; + ticker?: string; + tickers?: Record; + }, +): CatalogSource[] { + return kinds.flatMap((kind) => + [...new Set(uids)].map((uid) => ({ + instrumentType, + uid, + kind, + ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) + ? { ticker: range?.tickers?.[uid] || range?.ticker } + : {}), + ...(['coupons', 'dividends'].includes(kind) + ? { + rangeKey: range + ? JSON.stringify([range.from, range.to]) + : String(new Date().getUTCFullYear()), + } + : {}), + })), + ); +} +function publicInstrument(value: TbankShare | TbankBond) { + const bond = value as TbankBond; + return { + uid: value.uid, + figi: value.figi, + assetUid: value.assetUid, + name: value.name, + ticker: value.ticker, + isin: value.isin, + classCode: value.classCode, + realExchange: value.realExchange, + apiTradeAvailableFlag: value.apiTradeAvailableFlag, + currency: value.currency, + shareType: (value as TbankShare).shareType, + sector: value.sector, + lot: value.lot, + brand: value.brand ? { logoName: value.brand.logoName } : undefined, + first_1dayCandleDate: value.first_1dayCandleDate, + nominal: bond.nominal, + initialNominal: bond.initialNominal, + aciValue: bond.aciValue, + maturityDate: bond.maturityDate, + callDate: bond.callDate, + couponQuantityPerYear: bond.couponQuantityPerYear, + floatingCouponFlag: bond.floatingCouponFlag, + perpetualFlag: bond.perpetualFlag, + amortizationFlag: bond.amortizationFlag, + subordinatedFlag: bond.subordinatedFlag, + bondType: bond.bondType, + riskLevel: bond.riskLevel, + }; +} + +const previousCloseLookbackDays = 20; +const previousCloseLookbackMs = + previousCloseLookbackDays * MILLISECONDS_PER_DAY; +const payoutHistoryYears = 5; +const payoutFutureYears = 2; +export type CatalogPayload = { + instrument?: TbankShare | TbankBond | null; + price?: TbankLastPrice | null; + candle?: TbankCandle | null; + tradingStatus?: string | null; + description?: string | null; + fundamentals?: TbankFundamentals | null; + coupons?: TbankCoupon[]; + dividends?: TbankDividend[]; + bondEvents?: MoexBondEvent[]; + effectiveYield?: Awaited>; + creditRating?: Awaited>; +}; +function sourcePolicy(kind: CatalogSource['kind']) { + if ( + [ + 'last-price', + 'bond-current', + 'trading-status', + 'comparison', + 'yield', + ].includes(kind) + ) + return 'quote'; + if (kind === 'description' || kind === 'fundamentals') return 'company'; + if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar'; + return 'metadata'; +} +function producerKey(input: CatalogSource) { + return input.instrumentType === 'bond' && + (input.kind === 'detail' || input.kind === 'bond-current') + ? `bond-metadata:${input.uid}` + : catalogScope(input); +} + +const quotationScale = 9; +const quotationUnits = 1_000_000_000n; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts new file mode 100644 index 0000000..0f39396 --- /dev/null +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -0,0 +1,39 @@ +import type { TbankShare } from '../../../../integrations/tbank/tbank.api'; + +export const catalogSearchLimit = 30; + +export function selectCatalogInstruments( + found: T[], + query: string, + instrumentType: 'share' | 'bond', +): (T & { uid: string })[] { + const seen = new Set(); + const kind = + instrumentType === 'share' + ? 'INSTRUMENT_TYPE_SHARE' + : 'INSTRUMENT_TYPE_BOND'; + const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; + return found + .filter( + (instrument): instrument is T & { uid: string } => + Boolean(instrument.uid?.trim()) && instrument.instrumentKind === kind, + ) + .sort( + (a, b) => + Number(b.apiTradeAvailableFlag === true) - + Number(a.apiTradeAvailableFlag === true) || + Number(b.ticker?.toUpperCase() === query.toUpperCase()) - + Number(a.ticker?.toUpperCase() === query.toUpperCase()) || + Number(b.classCode === preferredClass) - + Number(a.classCode === preferredClass) || + a.uid.localeCompare(b.uid), + ) + .filter((instrument) => { + const key = instrument.isin?.trim() + ? `isin:${instrument.isin.trim().toUpperCase()}` + : `uid:${instrument.uid}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index 2b0db17..b8cd052 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -1,205 +1,85 @@ -import { - ConflictException, - Injectable, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; -import { LRUCache } from 'lru-cache'; +import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; +import { Injectable } from '@nestjs/common'; import { formatDecimal } from '../../../../../common/decimal'; import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; -import { - TbankAccessService, - TbankClient, - type TbankConnectionContext, -} from '../../../../../integrations/tbank/tbank.api'; import { decimal } from '../../../../../integrations/tbank/decimal'; -import { optional, timestamp } from '../../application/instrument-market'; -import { BondDto, BondSearchResultDto } from '../presentation/bond.dto'; +import { timestamp } from '../../application/instrument-market'; +import { + BondDto, + BondResourceDto, + BondSearchResourceDto, +} from '../presentation/bond.dto'; type IdentifiedBond = TbankBond & { uid: string }; import { money } from '../infrastructure/bond-money.mapper'; -const SEARCH_RESULTS_LIMIT = 30; @Injectable() export class BondCatalogService { - private readonly cache = new LRUCache>({ - max: 300, - ttl: 30_000, - }); - constructor( - private readonly access: TbankAccessService, - private readonly tbank: TbankClient, - ) {} - search(userId: string, query: string): Promise { - return this.cached(userId, `search:${query}`, async (connection) => { - const { token } = connection; - const found = await this.tbank.findBonds(token, query); - const bonds = selectBonds(found, query).slice(0, SEARCH_RESULTS_LIMIT); - if (!bonds.length) return []; - // Batch prices and share metadata with detail sections. Resolve readable - // aliases only when an issue is opened, not for every search result. - const [details, prices] = await Promise.all([ - Promise.all( - bonds.map( - async (bond) => - (await optional( - () => - this.bond(userId, connection, bond.uid) - .then(toBond) - .catch((error: unknown) => { - if (error instanceof NotFoundException) - return toBond(bond); - throw error; - }), - 'details', - [], - )) ?? toBond(bond), - ), - ), - optional( - () => - this.tbank.getLastPrices( - token, - bonds.map((bond) => bond.uid), - ), - 'prices', - [], - ), - ]); - return details.map((detail, index) => { - const short = toBond(bonds[index]); - const last = prices?.find( - (price) => price.instrumentUid === detail.instrumentUid, - ); - return { - ...detail, - classCode: detail.classCode ?? short.classCode, - isin: detail.isin ?? short.isin, - apiTradeAvailable: short.apiTradeAvailable, - pricePercent: last?.price ? formatDecimal(decimal(last.price)) : null, - priceAt: timestamp(last?.time), - }; - }); - }); - } - get(userId: string, uid: string): Promise { - return this.cached(userId, `bond:${uid}`, async (connection) => { - const result = toBond(await this.bond(userId, connection, uid)); - if ( - !result.ticker || - !/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker) - ) - return result; - try { - const match = await this.tickerMatch(userId, result.ticker); - if (match.uid === uid) result.slug = result.ticker.toUpperCase(); - } catch (error) { - // A readable alias is optional for an exact UID URL, never a reason to substitute another listing. - if ( - !(error instanceof NotFoundException) && - !(error instanceof ConflictException) && - !(error instanceof ServiceUnavailableException) - ) - throw error; - } - return result; - }); - } - async getByTicker(userId: string, ticker: string): Promise { - const match = await this.tickerMatch(userId, ticker); + constructor(private readonly discovery: CatalogDiscoveryService) {} + async search( + userId: string, + query: string, + observe = false, + ): Promise { + const result = await this.discovery.search( + userId, + { instrumentType: 'bond', query }, + observe, + ); return { - ...(await this.get(userId, match.uid)), - slug: ticker.trim().toUpperCase(), + ...result, + data: + result.data?.map((item) => { + const short = toBond(item); + const detail = item.detail + ? toBond({ ...item.detail, uid: item.uid }) + : short; + const last = item.price; + return { + ...detail, + classCode: detail.classCode ?? short.classCode, + isin: detail.isin ?? short.isin, + apiTradeAvailable: short.apiTradeAvailable, + pricePercent: last?.price + ? formatDecimal(decimal(last.price)) + : null, + priceAt: timestamp(last?.time), + }; + }) ?? null, }; } - private tickerMatch(userId: string, ticker: string): Promise { - const normalized = ticker.trim().toUpperCase(); - return this.cached(userId, `ticker:${normalized}`, async (connection) => { - const { token } = connection; - const found = await this.tbank.findBonds(token, normalized); - const matches = selectBonds( - found.filter( - (item) => item.ticker?.trim().toUpperCase() === normalized, - ), - normalized, - ); - if (!matches.length) throw new NotFoundException(); - if (matches.length > 1) - throw new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' }); - return matches[0]; - }); - } - bond( + async get( userId: string, - connection: TbankConnectionContext, uid: string, - ): Promise { - return this.cachedForConnection( + observe = false, + ): Promise { + const result = await this.discovery.detail(userId, 'bond', uid, observe); + return { + ...result, + data: result.data + ? { ...toBond(result.data), slug: result.data.slug } + : null, + }; + } + async getByTicker( + userId: string, + ticker: string, + observe = false, + ): Promise { + const result = await this.discovery.byTicker( userId, - connection, - `metadata:${uid}`, - async () => { - const bond = await this.tbank.getBond(connection.token, uid); - if (!bond || bond.uid !== uid) throw new NotFoundException(); - return { ...bond, uid }; - }, + 'bond', + ticker, + observe, ); + return { + ...result, + data: result.data + ? { ...toBond(result.data), slug: result.data.slug } + : null, + }; } - cached( - userId: string, - key: string, - operation: (connection: TbankConnectionContext) => Promise, - ): Promise { - return this.access.withConnection(userId, (connection) => - this.cachedForConnection(userId, connection, key, () => - operation(connection), - ), - ); - } - cachedForConnection( - userId: string, - { generation }: TbankConnectionContext, - key: string, - operation: () => Promise, - ): Promise { - const cacheKey = JSON.stringify([userId, generation, key]); - const cached = this.cache.get(cacheKey); - if (cached) return cached as Promise; - const pending = operation(); - this.cache.set(cacheKey, pending); - return pending.catch((error) => { - if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey); - throw error; - }); - } -} - -function selectBonds(found: TbankBond[], query: string): IdentifiedBond[] { - const seen = new Set(); - return found - .filter( - (bond): bond is IdentifiedBond => - Boolean(bond.uid) && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND', - ) - .sort( - (a, b) => - Number(b.apiTradeAvailableFlag === true) - - Number(a.apiTradeAvailableFlag === true) || - Number(b.ticker?.toUpperCase() === query.toUpperCase()) - - Number(a.ticker?.toUpperCase() === query.toUpperCase()) || - Number(b.classCode === 'TQOB') - Number(a.classCode === 'TQOB') || - a.uid.localeCompare(b.uid), - ) - .filter((bond) => { - // A missing ISIN must not collapse unrelated catalog instruments. - const key = bond.isin?.trim() - ? `isin:${bond.isin.trim().toUpperCase()}` - : `uid:${bond.uid}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); } function toBond(bond: IdentifiedBond): BondDto { diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index beafae8..7e82d38 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -1,208 +1,251 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { decimalFromString, - divide, formatDecimal, multiply, + divide, } from '../../../../../common/decimal'; -import { MoexClient } from '../../../../../integrations/moex/moex.api'; -import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr.api'; import { - TbankClient, - type TbankConnectionContext, + TbankAccessService, + type TbankCoupon, + type TbankBond, } from '../../../../../integrations/tbank/tbank.api'; -import { PortfolioService } from '../../../portfolio/portfolio.api'; import { - moscowDay, - optional, - timestamp, -} from '../../application/instrument-market'; + PortfolioService, + ForecastService, +} from '../../../portfolio/portfolio.api'; +import { + CatalogEnrichmentService, + type CatalogPayload, +} from '../../application/catalog-enrichment.service'; +import { moscowDay, timestamp } from '../../application/instrument-market'; import { instrumentPosition } from '../../application/instrument-position'; import { BondCouponsDto, - BondMetricsDto, + BondCouponsResourceDto, BondPositionDto, + BondMetricsDto, + BondMetricsResourceDto, } from '../presentation/bond.dto'; - -import { BondMarketService } from './bond-market.service'; import { money } from '../infrastructure/bond-money.mapper'; -import { BondCatalogService } from './bond-catalog.service'; - +import { bondQuote } from './bond-market.service'; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; const COUPON_YIELD_DECIMAL_PLACES = 4; @Injectable() export class BondIncomeService { constructor( - private readonly catalog: BondCatalogService, - private readonly tbank: TbankClient, private readonly portfolios: PortfolioService, - private readonly market: BondMarketService, - private readonly moex: MoexClient, - private readonly ratings: NcrRatingsClient, + private readonly sources: CatalogEnrichmentService, + private readonly access: TbankAccessService, + private readonly forecasts: ForecastService, ) {} - metrics(userId: string, uid: string): Promise { - return this.catalog.cached(userId, `metrics:${uid}`, async (connection) => { - const instrument = await this.catalog.bond(userId, connection, uid); - const { ticker, isin, classCode } = instrument; - const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; - const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; - const unavailable: string[] = []; - const [events, quote, effectiveYield, creditRating] = await Promise.all([ - optional( - () => this.couponEvents(userId, connection, uid, from, to), - 'coupons', - unavailable, - ), - optional(() => this.market.quote(userId, uid), 'quote', unavailable), - ticker && isin && classCode - ? optional( - () => this.moex.bondYield(ticker, isin, classCode), - 'yield', - unavailable, - ) - : null, - isin - ? optional(() => this.ratings.rating(isin), 'rating', unavailable) - : null, - ]); - const next = events - ?.filter((event) => { - const date = timestamp(event.couponDate)?.slice(0, 10); - return date && date >= today; - }) - .sort( - (a, b) => - Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds), - )[0]; - const coupon = money(next?.payOneBond); - const period = next?.couponPeriod; - const couponPeriodDays = - period && Number.isInteger(period) && period > 0 ? period : null; - const clean = quote?.cleanPrice; - const couponYieldPercent = - coupon && - couponPeriodDays && - clean && - coupon.currency === clean.currency && - decimalFromString(coupon.value).value >= 0n && - decimalFromString(clean.value).value > 0n - ? formatDecimal( - divide( - multiply( - decimalFromString(coupon.value), - decimalFromString('36500'), - ), - multiply( - decimalFromString(String(couponPeriodDays)), - decimalFromString(clean.value), - ), - COUPON_YIELD_DECIMAL_PLACES, - ), - ) - : null; - const couponType = - next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' - ? next.couponType - : instrument.floatingCouponFlag === true - ? 'COUPON_TYPE_FLOATING' - : null; - return { - creditRating, - coupon, - couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null, - couponType, - couponPeriodDays, - couponYieldPercent, - couponYieldPriceAt: - couponYieldPercent !== null ? (quote?.priceAt ?? null) : null, - effectiveYield, - unavailable, - observedAt: new Date().toISOString(), - }; - }); - } - private couponEvents( + async metrics( userId: string, - connection: TbankConnectionContext, uid: string, - from: string, - to: string, - ) { - return this.catalog.cachedForConnection( + observe = false, + ): Promise { + const result = await this.sources.section( userId, - connection, - `coupon-events:${uid}:${from}:${to}`, - () => this.tbank.getBondCoupons(connection.token, uid, from, to), + 'bond', + uid, + 'metrics', + observe, ); + return { + data: result.values.instrument + ? bondMetrics(result.values, result.unavailable, result.observedAt) + : null, + meta: result.meta, + }; } - coupons(userId: string, uid: string): Promise { - return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => { - await this.catalog.bond(userId, connection, uid); - const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; - const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; - const unavailable: string[] = []; - const [events, position] = await Promise.all([ - this.couponEvents(userId, connection, uid, from, to), - optional(() => this.position(userId, uid), 'position', unavailable), - ]); - const quantity = - position?.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; - const coupons = events - .map((event) => { - const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; - const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; - const perBond = money(event.payOneBond); - const timing = !paymentDate - ? ('undated' as const) - : paymentDate >= today - ? ('upcoming' as const) - : ('past' as const); - return { - number: event.couponNumber ?? null, - paymentDate, - recordDate, - perBond, - couponType: event.couponType ?? null, - periodDays: event.couponPeriod ?? null, - timing, - estimatedAmount: - quantity && - perBond && - timing === 'upcoming' && - recordDate && - recordDate >= today - ? { - currency: perBond.currency, - value: formatDecimal( - multiply(quantity, decimalFromString(perBond.value)), - ), - } - : null, - }; - }) - .sort((a, b) => - (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''), - ); - return { - coupons, - from, - to, - observedAt: new Date().toISOString(), - positionAt: quantity ? (position?.observedAt ?? null) : null, - estimateUnavailable: unavailable.length > 0, - positionIsStale: position?.isStale ?? false, - }; + + async readPosition(userId: string, uid: string, observe = false) { + const saved = await this.portfolios.read(userId, 'all', observe); + return { + data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null, + meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, + }; + } + async coupons( + userId: string, + uid: string, + observe = false, + ): Promise { + const connection = await this.access.local(userId); + const today = moscowDay(new Date().toISOString()); + const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; + const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; + const portfolio = await this.portfolios.read(userId, 'all', observe); + const sources = await this.sources.readMany( + userId, + 'bond', + [uid], + observe, + ['detail', 'coupons'], + { from, to }, + ); + if (sources[0].meta.availability === 'complete' && !sources[0].instrument) + throw new NotFoundException(); + const events = sources[1].payload?.coupons; + const position = portfolio.data + ? instrumentPosition(portfolio.data, uid, 'BOND') + : null; + const projected = events + ? bondCoupons( + events, + position, + { from, to, today }, + sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), + !portfolio.data, + ) + : undefined; + const result = await this.forecasts.result({ + userId, + revision: connection.revision, + membershipRevision: connection.membershipRevision, + scopeKey: `bond/coupons/${JSON.stringify([uid, from, to])}`, + portfolio: portfolio.meta, + sources, + candidate: + portfolio.data && sources[0].instrument ? projected : undefined, }); - } - async position(userId: string, uid: string): Promise { - return this.catalog.cached(userId, `position:${uid}`, async () => - instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'), - ); + return result.data || !projected + ? result + : { + data: bondCoupons( + events ?? [], + null, + { from, to, today }, + projected.observedAt, + true, + ), + meta: { ...result.meta, availability: 'partial' }, + }; } } + +export function bondCoupons( + events: TbankCoupon[], + position: BondPositionDto | null, + window: { from: string; to: string; today: string }, + observedAt: string, + positionUnavailable: boolean, +): BondCouponsDto { + const { from, to, today } = window; + const quantity = + position?.accounts.length && + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) + ? decimalFromString(position.quantity) + : null; + const coupons = events + .map((event) => { + const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; + const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; + const perBond = money(event.payOneBond); + const timing = !paymentDate + ? ('undated' as const) + : paymentDate >= today + ? ('upcoming' as const) + : ('past' as const); + return { + number: event.couponNumber ?? null, + paymentDate, + recordDate, + perBond, + couponType: event.couponType ?? null, + periodDays: event.couponPeriod ?? null, + timing, + estimatedAmount: + quantity && + perBond && + timing === 'upcoming' && + recordDate && + recordDate >= today + ? { + currency: perBond.currency, + value: formatDecimal( + multiply(quantity, decimalFromString(perBond.value)), + ), + } + : null, + }; + }) + .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); + return { + coupons, + from, + to, + observedAt: observedAt, + positionAt: quantity ? (position?.observedAt ?? null) : null, + estimateUnavailable: positionUnavailable, + positionIsStale: position?.isStale ?? false, + }; +} + +export function bondMetrics( + values: CatalogPayload, + unavailable: string[], + observedAt: string, +): BondMetricsDto { + const instrument = values.instrument as TbankBond; + const events = values.coupons; + const quote = bondQuote(values, unavailable, observedAt); + const effectiveYield = values.effectiveYield ?? null; + const creditRating = values.creditRating ?? null; + const today = moscowDay(new Date().toISOString()); + const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; + const next = events + ?.filter((event) => { + const date = timestamp(event.couponDate)?.slice(0, 10); + return date && date >= today && date <= to; + }) + .sort( + (a, b) => Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds), + )[0]; + const coupon = money(next?.payOneBond); + const period = next?.couponPeriod; + const couponPeriodDays = + period && Number.isInteger(period) && period > 0 ? period : null; + const clean = quote?.cleanPrice; + const couponYieldPercent = + coupon && + couponPeriodDays && + clean && + coupon.currency === clean.currency && + decimalFromString(coupon.value).value >= 0n && + decimalFromString(clean.value).value > 0n + ? formatDecimal( + divide( + multiply( + decimalFromString(coupon.value), + decimalFromString('36500'), + ), + multiply( + decimalFromString(String(couponPeriodDays)), + decimalFromString(clean.value), + ), + COUPON_YIELD_DECIMAL_PLACES, + ), + ) + : null; + const couponType = + next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' + ? next.couponType + : instrument.floatingCouponFlag === true + ? 'COUPON_TYPE_FLOATING' + : null; + return { + creditRating, + coupon, + couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null, + couponType, + couponPeriodDays, + couponYieldPercent, + couponYieldPriceAt: + couponYieldPercent !== null ? (quote?.priceAt ?? null) : null, + effectiveYield, + unavailable, + observedAt, + }; +} diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index db7a57b..6365538 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -1,4 +1,10 @@ -import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { CandleService } from '../../application/candle.service'; +import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; +import { + CatalogEnrichmentService, + type CatalogPayload, +} from '../../application/catalog-enrichment.service'; +import { Injectable } from '@nestjs/common'; import { add, decimalFromString, @@ -7,192 +13,110 @@ import { multiply, subtract, } from '../../../../../common/decimal'; -import { TbankClient } from '../../../../../integrations/tbank/tbank.api'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { timestamp } from '../../application/instrument-market'; import { - optional, - previousClose, - timestamp, -} from '../../application/instrument-market'; -import { - BondCandlesDto, BondCandlesQueryDto, BondQuoteDto, + BondQuoteResourceDto, } from '../presentation/bond.dto'; -const dayMs = 86_400_000; - import { money } from '../infrastructure/bond-money.mapper'; -import { BondCatalogService } from './bond-catalog.service'; -const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20; -const HALF_YEAR_MONTHS = 6; -const YEAR_MONTHS = 12; -const CANDLE_WINDOW_DAYS = 365; const PRICE_DECIMAL_PLACES = 9; @Injectable() export class BondMarketService { constructor( - private readonly catalog: BondCatalogService, - private readonly tbank: TbankClient, + private readonly history: CandleService, + private readonly sources: CatalogEnrichmentService, ) {} - quote(userId: string, uid: string): Promise { - return this.catalog.cached(userId, `quote:${uid}`, async (connection) => { - const { token } = connection; - const instrument = await this.catalog.bond(userId, connection, uid); - const unavailable: string[] = []; - const [last, tradingStatus] = await Promise.all([ - optional( - () => this.tbank.getLastPrice(token, uid), - 'price', - unavailable, - ), - optional( - () => this.tbank.getTradingStatus(token, uid), - 'status', - unavailable, - ), - ]); - const priceAt = timestamp(last?.time); - const pricePercent = last?.price - ? formatDecimal(decimal(last.price)) - : null; - const nominal = money(instrument.nominal); - const aci = money(instrument.aciValue); - const cleanPrice = - pricePercent !== null && - nominal && - decimalFromString(nominal.value).value > 0n - ? { - currency: nominal.currency, - value: formatDecimal( - divide( - multiply( - decimalFromString(pricePercent), - decimalFromString(nominal.value), - ), - decimalFromString('100'), - PRICE_DECIMAL_PLACES, - ), - ), - } - : null; - const dirtyPrice = - cleanPrice && aci && aci.currency === cleanPrice.currency - ? { - currency: cleanPrice.currency, - value: formatDecimal( - add( - decimalFromString(cleanPrice.value), - decimalFromString(aci.value), - ), - ), - } - : null; - const previous = priceAt - ? await optional( - async () => - previousClose( - await this.tbank.getCandles( - token, - uid, - new Date( - Date.parse(priceAt) - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs, - ), - new Date(priceAt), - ), - priceAt, - ), - 'comparison', - unavailable, - ) - : null; - return { - pricePercent, - cleanPrice, - aci, - dirtyPrice, - priceAt, - tradingStatus, - changePercentagePoints: - last?.price && previous?.close - ? formatDecimal( - subtract(decimal(last.price), decimal(previous.close)), - ) - : null, - previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, - unavailable, - observedAt: new Date().toISOString(), - }; - }); + async quote( + userId: string, + uid: string, + observe = false, + ): Promise { + const result = await this.sources.section( + userId, + 'bond', + uid, + 'quote', + observe, + ); + return { + data: result.values.instrument + ? bondQuote(result.values, result.unavailable, result.observedAt) + : null, + meta: result.meta, + }; } candles( userId: string, uid: string, period: BondCandlesQueryDto['period'] = 'month', - ): Promise { - return this.catalog.cached( + observe = false, + ) { + return this.history.read( userId, - `candles:${uid}:${period}`, - async (connection) => { - const { token } = connection; - const bond = await this.catalog.bond(userId, connection, uid); - const historyFrom = timestamp(bond.first_1dayCandleDate); - const to = new Date(); - const from = new Date(to); - from.setUTCMonth( - from.getUTCMonth() - - (period === 'month' - ? 1 - : period === 'halfYear' - ? HALF_YEAR_MONTHS - : YEAR_MONTHS), - ); - if (period === 'all') { - if (!historyFrom) - throw new ServiceUnavailableException({ - code: 'BOND_HISTORY_START_UNAVAILABLE', - }); - from.setTime(Date.parse(historyFrom)); - } else if (historyFrom && Date.parse(historyFrom) > from.valueOf()) - from.setTime(Date.parse(historyFrom)); - const collected = new Map(); - // Conservative one-year windows also work with older deployed RPC limits. - for (let start = from.valueOf(); start < to.valueOf();) { - const end = Math.min( - start + CANDLE_WINDOW_DAYS * dayMs, - to.valueOf(), - ); - for (const candle of await this.tbank.getCandles( - token, - uid, - new Date(start), - new Date(end), - )) { - const time = timestamp(candle.time); - if ( - time && - candle.close && - Date.parse(time) >= from.valueOf() - dayMs && - Date.parse(time) <= to.valueOf() - ) - collected.set(time, { - time, - close: formatDecimal(decimal(candle.close)), - isComplete: candle.isComplete === true, - }); - } - start = end; - } - return { - candles: [...collected.values()].sort((a, b) => - a.time.localeCompare(b.time), - ), - currency: null, - historyFrom, - observedAt: new Date().toISOString(), - }; - }, + { uid, instrumentType: 'bond', period }, + observe, ); } } + +export function bondQuote( + values: CatalogPayload, + unavailable: string[], + observedAt: string, +): BondQuoteDto { + const { price: last, candle: previous, tradingStatus = null } = values; + const instrument = values.instrument as TbankBond | undefined; + const priceAt = timestamp(last?.time); + const pricePercent = last?.price ? formatDecimal(decimal(last.price)) : null; + const nominal = money(instrument?.nominal); + const aci = money(instrument?.aciValue); + const cleanPrice = + pricePercent !== null && + nominal && + decimalFromString(nominal.value).value > 0n + ? { + currency: nominal.currency, + value: formatDecimal( + divide( + multiply( + decimalFromString(pricePercent), + decimalFromString(nominal.value), + ), + decimalFromString('100'), + PRICE_DECIMAL_PLACES, + ), + ), + } + : null; + const dirtyPrice = + cleanPrice && aci && aci.currency === cleanPrice.currency + ? { + currency: cleanPrice.currency, + value: formatDecimal( + add( + decimalFromString(cleanPrice.value), + decimalFromString(aci.value), + ), + ), + } + : null; + return { + pricePercent, + cleanPrice, + aci, + dirtyPrice, + priceAt, + tradingStatus, + changePercentagePoints: + last?.price && previous?.close + ? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) + : null, + previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, + unavailable, + observedAt, + }; +} diff --git a/src/modules/investments/instruments/bonds/application/bond.spec.ts b/src/modules/investments/instruments/bonds/application/bond.spec.ts index 5b6522a..6f8f234 100644 --- a/src/modules/investments/instruments/bonds/application/bond.spec.ts +++ b/src/modules/investments/instruments/bonds/application/bond.spec.ts @@ -1,24 +1,12 @@ -import { - ConflictException, - NotFoundException, - ServiceUnavailableException, - UnauthorizedException, -} from '@nestjs/common'; -import assert from 'node:assert/strict'; -import { MoexClient } from '../../../../../integrations/moex/moex.client'; -import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr-ratings.client'; -import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service'; -import { TbankClient } from '../../../../../integrations/tbank/tbank.client'; -import { PortfolioService } from '../../../portfolio/application/portfolio.service'; -import { BondIncomeService } from './bond-income.service'; -import { BondMarketService } from './bond-market.service'; -import { BondCatalogService } from './bond-catalog.service'; -jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({ - TbankAccessService: class TbankAccessService {}, +import { bondCoupons, bondMetrics } from './bond-income.service'; +import { bondQuote } from './bond-market.service'; +import { instrumentPosition } from '../../application/instrument-position'; +import type { PortfolioDto } from '../../../portfolio/portfolio.api'; +jest.mock('../../../../../prisma/prisma.service', () => ({ + PrismaService: class {}, })); const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) }); const money = (units: string, currency = 'rub') => ({ units, currency }); -const cacheClock = performance; const bond = { uid: 'uid', ticker: 'BOND', @@ -30,469 +18,160 @@ const bond = { amortizationFlag: true, first_1dayCandleDate: time('2024-01-01'), }; -function setup() { - let generation = 'a'; - const tbank = { - findBonds: jest.fn().mockResolvedValue([bond]), - getBond: jest.fn().mockResolvedValue(bond), - getLastPrice: jest.fn().mockResolvedValue({ - instrumentUid: 'uid', - price: { units: '98', nano: 125000000 }, - time: time('2026-09-10T10:00:00Z'), - }), - getLastPrices: jest.fn().mockResolvedValue([]), - getTradingStatus: jest - .fn() - .mockResolvedValue('SECURITY_TRADING_STATUS_NORMAL_TRADING'), - getCandles: jest.fn().mockResolvedValue([ - { time: time('2026-09-09'), close: { units: '99' }, isComplete: true }, - { time: time('2026-09-10'), close: { units: '100' }, isComplete: true }, - ]), - getBondCoupons: jest.fn().mockResolvedValue([]), - }; - const access = { - withConnection: jest.fn( - (user: string, handler: (context: unknown) => unknown) => - handler({ token: user, generation }), - ), - }; - const portfolios = { - getAll: jest.fn().mockResolvedValue({ - positions: [], - lastSuccessfulAt: '2026-09-10T00:00:00Z', - isStale: false, - }), - }; - const ratings = { rating: jest.fn().mockResolvedValue(null) }; - const moex = { bondYield: jest.fn().mockResolvedValue(null) }; - const service = new BondCatalogService( - access as unknown as TbankAccessService, - tbank as unknown as TbankClient, +describe('bond financial projections', () => { + beforeEach(() => + jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')), ); - const market = new BondMarketService( - service, - tbank as unknown as TbankClient, - ); - const income = new BondIncomeService( - service, - tbank as unknown as TbankClient, - portfolios as unknown as PortfolioService, - market, - moex as unknown as MoexClient, - ratings as unknown as NcrRatingsClient, - ); - return { - market, - income, - service, - ratings, - moex, - tbank, - access, - portfolios, - changeGeneration: () => { - generation = 'b'; - }, - }; -} -describe('bond market use case', () => { - beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')); - jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now()); - }); - afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - }); - it('shows the next coupon and its annualized current yield without extra T-Bank calls', async () => { - const { tbank, moex, market, income } = setup(); - tbank.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB' }); - tbank.getBondCoupons.mockResolvedValue([ - { - couponDate: time('2026-12-10'), - payOneBond: money('40'), - couponPeriod: 91, - couponType: 'COUPON_TYPE_VARIABLE', - }, - { - couponDate: time('2026-10-10'), - payOneBond: money('30'), - couponPeriod: 90, - couponType: 'COUPON_TYPE_FIX', - }, - { - couponDate: time('2026-09-01'), - payOneBond: money('90'), - couponPeriod: 90, - }, - ]); - const effectiveYield = { - value: '15.6262', - pricePercent: '53.384', - date: '2041-05-15', - dateType: 'MATDATE', - tradeAt: '2026-09-10T18:49:39Z', - }; - moex.bondYield.mockResolvedValue(effectiveYield); - const [metrics] = await Promise.all([ - income.metrics('user', 'uid'), - income.coupons('user', 'uid'), - market.quote('user', 'uid'), - ]); - expect(metrics).toMatchObject({ - coupon: { value: '30', currency: 'RUB' }, - couponDate: '2026-10-10', - couponType: 'COUPON_TYPE_FIX', - couponPeriodDays: 90, - couponYieldPercent: '24.7983', - effectiveYield, - }); - expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'ISIN', 'TQOB'); - expect(tbank.getBond).toHaveBeenCalledTimes(1); - expect(tbank.getBondCoupons).toHaveBeenCalledTimes(1); - expect(tbank.getLastPrice).toHaveBeenCalledTimes(1); - }); - it.each(['unknown', 'currency', 'period', 'zero-price'])( - 'does not invent coupon yield with %s inputs', - async (scenario) => { - const { tbank, income } = setup(); - tbank.getBondCoupons.mockResolvedValue([ - { - couponDate: time('2026-10-10'), - payOneBond: - scenario === 'unknown' - ? undefined - : money('30', scenario === 'currency' ? 'usd' : 'rub'), - couponPeriod: scenario === 'period' ? 0 : 90, - }, - { - couponDate: time('2026-12-10'), - payOneBond: money('50'), - couponPeriod: 90, - }, - ]); - if (scenario === 'zero-price') - tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } }); - expect(await income.metrics('user', 'uid')).toMatchObject({ - couponDate: '2026-10-10', - couponYieldPercent: null, - }); - }, - ); - it('keeps coupons when MOEX fails and never substitutes a risk level for a credit rating', async () => { - const { tbank, moex, income } = setup(); - tbank.getBond.mockResolvedValue({ - ...bond, - classCode: 'TQOB', - floatingCouponFlag: true, - riskLevel: 'RISK_LEVEL_LOW', - }); - moex.bondYield.mockRejectedValue(new ServiceUnavailableException()); - expect(await income.metrics('user', 'uid')).toMatchObject({ - effectiveYield: null, - coupon: null, - couponType: 'COUPON_TYPE_FLOATING', - couponYieldPercent: null, - unavailable: ['yield'], - }); - tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException()); - await expect(income.metrics('other', 'uid')).rejects.toBeInstanceOf( - UnauthorizedException, - ); - }); - it('returns only the rating of the exact issue and isolates a registry outage', async () => { - const { ratings, income } = setup(); - const creditRating = { - agency: 'NCR', - value: 'AAA.ru', - status: 'active', - date: '2026-04-17', - sourceUrl: 'https://ratings.ru/ratings/issues/123/', - observedAt: '2026-09-10T10:00:00Z', - }; - ratings.rating.mockResolvedValue(creditRating); - expect(await income.metrics('user', 'uid')).toMatchObject({ - creditRating, - }); - expect(ratings.rating).toHaveBeenCalledWith('ISIN'); - ratings.rating.mockRejectedValue(new ServiceUnavailableException()); - expect(await income.metrics('other', 'uid')).toMatchObject({ - creditRating: null, - unavailable: ['rating'], - }); - }); - it('opens concurrent sections within one upstream metadata request budget', async () => { - const { service, tbank, market, income } = setup(); - tbank.getBond - .mockReset() - .mockResolvedValueOnce(bond) - .mockRejectedValue( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - await expect( - Promise.all([ - service.getByTicker('user', 'BOND'), - market.quote('user', 'uid'), - market.candles('user', 'uid'), - income.coupons('user', 'uid'), - income.metrics('user', 'uid'), - ]), - ).resolves.toHaveLength(5); - expect(tbank.getBond).toHaveBeenCalledTimes(1); - expect(tbank.findBonds).toHaveBeenCalledTimes(1); - }); - it('searches thirty issues without a separate ticker lookup for each result', async () => { - const { service, tbank, market, income } = setup(); - const issues = Array.from({ length: 30 }, (_, index) => ({ - ...bond, - uid: `uid-${index}`, - isin: `ISIN-${index}`, - ticker: `BOND-${index}`, - })); - tbank.findBonds.mockResolvedValue(issues); - tbank.getBond.mockImplementation((_token: string, uid: string) => - Promise.resolve(issues.find((item) => item.uid === uid)), - ); - expect(await service.search('user', 'BOND')).toHaveLength(30); - expect(tbank.findBonds).toHaveBeenCalledTimes(1); - expect(tbank.getBond).toHaveBeenCalledTimes(30); - expect(tbank.getLastPrices).toHaveBeenCalledTimes(1); - await service.getByTicker('user', 'BOND-0'); - await Promise.all([ - market.quote('user', 'uid-0'), - market.candles('user', 'uid-0'), - income.coupons('user', 'uid-0'), - ]); - expect(tbank.getBond).toHaveBeenCalledTimes(30); - expect(tbank.findBonds).toHaveBeenCalledTimes(2); - }); - it('refreshes current nominal and accrued interest after the metadata TTL', async () => { - const { service, tbank, market } = setup(); - await service.get('user', 'uid'); - await jest.advanceTimersByTimeAsync(30_001); - tbank.getBond.mockResolvedValue({ - ...bond, - nominal: money('400'), - aciValue: money('3'), - }); - expect(await market.quote('user', 'uid')).toMatchObject({ - cleanPrice: { value: '392.5' }, - dirtyPrice: { value: '395.5' }, - }); - expect(tbank.getBond).toHaveBeenCalledTimes(2); - }); - it('keeps late metadata responses within their original connection generation', async () => { - const { tbank, changeGeneration, market } = setup(); - let resolveOld: ((value: typeof bond) => void) | undefined; - tbank.getBond.mockReturnValueOnce( - new Promise((resolve) => { - resolveOld = resolve; - }), - ); - const oldQuote = market.quote('user', 'uid'); - changeGeneration(); - tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') }); - expect(await market.quote('user', 'uid')).toMatchObject({ - cleanPrice: { value: '392.5' }, - }); - assert.ok(resolveOld); - resolveOld(bond); - await oldQuote; - expect(await market.quote('user', 'uid')).toMatchObject({ - cleanPrice: { value: '392.5' }, - }); - expect(tbank.getBond).toHaveBeenCalledTimes(2); - }); - it('keeps percent quotes separate from money and uses current amortized nominal', async () => { - const { market } = setup(); - expect(await market.quote('user', 'uid')).toMatchObject({ - pricePercent: '98.125', - cleanPrice: { value: '490.625', currency: 'RUB' }, - aci: { value: '12', currency: 'RUB' }, - dirtyPrice: { value: '502.625', currency: 'RUB' }, - changePercentagePoints: '-0.875', - previousCloseDate: '2026-09-09', - }); - }); - it('does not invent dirty prices or mix currencies when accrued interest is absent or mismatched', async () => { - const { tbank, market } = setup(); - tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined }); - expect(await market.quote('user', 'uid')).toMatchObject({ - aci: null, - dirtyPrice: null, - }); - tbank.getBond.mockResolvedValueOnce({ - ...bond, - aciValue: money('12', 'usd'), - }); - expect(await market.quote('other', 'uid')).toMatchObject({ - dirtyPrice: null, - }); - tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined }); - expect(await market.quote('third', 'uid')).toMatchObject({ - pricePercent: '98.125', - cleanPrice: null, - dirtyPrice: null, - }); - }); - it('preserves absent coupon flags and dates without reporting false or zero', async () => { - const { service } = setup(); - expect(await service.get('user', 'uid')).toMatchObject({ - floatingCoupon: null, - perpetual: null, - couponQuantityPerYear: null, - maturityDate: null, - nominal: { value: '500', currency: 'RUB' }, - amortization: true, - }); - }); - it('keeps distinct issues, deduplicates ISIN listings, and prices the selected exact UID', async () => { - const { service, tbank } = setup(); - const listings = [ - { ...bond, uid: 'off', apiTradeAvailableFlag: false }, - { ...bond, uid: 'uid', apiTradeAvailableFlag: true }, - { ...bond, uid: 'second', isin: 'OTHER', ticker: 'OTHER' }, - { ...bond, uid: 'share', instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, - { ...bond, uid: undefined }, - { ...bond, uid: '' }, - ]; - tbank.findBonds.mockResolvedValue(listings); - tbank.getBond.mockImplementation((_token: string, uid: string) => - Promise.resolve(listings.find((b) => b.uid === uid)), - ); - tbank.getLastPrices.mockResolvedValue([ - { instrumentUid: 'uid', price: { units: '99' } }, - { instrumentUid: 'off', price: { units: '1' } }, - ]); - const results = await service.search('user', 'BOND'); - expect(results.map((x) => x.instrumentUid)).toEqual(['uid', 'second']); - expect(results[0]).toMatchObject({ pricePercent: '99', slug: 'uid' }); - expect((await service.get('user', 'off')).slug).toBe('off'); - }); - it('rejects ambiguous tickers and wrong instrument identities', async () => { - const { service, tbank } = setup(); - tbank.findBonds.mockResolvedValue([ - bond, - { ...bond, uid: 'other', isin: 'OTHER' }, - ]); - await expect(service.getByTicker('user', 'bond')).rejects.toBeInstanceOf( - ConflictException, - ); - tbank.getBond.mockResolvedValue({ ...bond, uid: 'other' }); - await expect(service.get('user', 'uid')).rejects.toBeInstanceOf( - NotFoundException, - ); - }); - it('preserves catalog results on enrichment outage but never suppresses auth failures', async () => { - const { service, tbank } = setup(); - tbank.getBond.mockRejectedValue(new ServiceUnavailableException()); - tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException()); - expect(await service.search('user', 'BOND')).toMatchObject([ - { instrumentUid: 'uid', pricePercent: null, nominal: { value: '500' } }, - ]); - tbank.getBond.mockRejectedValue(new UnauthorizedException()); - await expect(service.search('other', 'BOND')).rejects.toBeInstanceOf( - UnauthorizedException, - ); - }); - it('keeps daily candles in percent, deduplicates boundaries and requires an all-history start', async () => { - const { tbank, market } = setup(); - const history = await market.candles('user', 'uid', 'all'); - expect(history.currency).toBeNull(); - expect(history.candles.map((c) => c.close)).toEqual(['99', '100']); - for (const [, , from, to] of tbank.getCandles.mock.calls as [ - string, - string, - Date, - Date, - ][]) - expect(to.valueOf() - from.valueOf()).toBeLessThanOrEqual(365 * 86400000); - tbank.getBond.mockResolvedValue({ - ...bond, - first_1dayCandleDate: undefined, - }); - await expect(market.candles('other', 'uid', 'all')).rejects.toBeInstanceOf( - ServiceUnavailableException, - ); - }); - it('isolates caches by user and connection generation and retries failures', async () => { - const { service, tbank, changeGeneration } = setup(); - await Promise.all([service.get('a', 'uid'), service.get('a', 'uid')]); - expect(tbank.getBond).toHaveBeenCalledTimes(1); - await service.get('b', 'uid'); - changeGeneration(); - await service.get('a', 'uid'); - expect(tbank.getBond).toHaveBeenCalledTimes(3); - tbank.getBond.mockRejectedValueOnce(new ServiceUnavailableException()); - await expect(service.get('c', 'uid')).rejects.toBeInstanceOf( - ServiceUnavailableException, - ); - await expect(service.get('c', 'uid')).resolves.toMatchObject({ - instrumentUid: 'uid', - }); - }); - it('estimates only future unfixed coupons from positive exact-UID bond holdings', async () => { - const { tbank, portfolios, income } = setup(); + afterEach(() => jest.useRealTimers()); + it('estimates only future unfixed payouts from exact UID holdings', () => { const holding = { instrumentUid: 'uid', type: 'BOND', quantity: '2', - averagePurchasePrice: { value: '490', currency: 'RUB' }, + averagePurchasePrice: null, currentValue: { value: '981.25', currency: 'RUB' }, - absoluteReturn: { value: '1.25', currency: 'RUB' }, + absoluteReturn: null, }; - portfolios.getAll.mockResolvedValue({ - positions: [ - holding, - { ...holding, instrumentUid: 'other', quantity: '99' }, - { ...holding, type: 'STOCK', quantity: '999' }, + const position = instrumentPosition( + { + positions: [ + holding, + { ...holding, instrumentUid: 'other', quantity: '99' }, + { ...holding, type: 'STOCK', quantity: '999' }, + ], + lastSuccessfulAt: '2026-09-10T00:00:00Z', + isStale: true, + } as PortfolioDto, + 'uid', + 'BOND', + ); + const result = bondCoupons( + [ + { + couponNumber: '1', + couponDate: time('2026-10-01'), + fixDate: time('2026-09-29'), + payOneBond: money('10'), + }, + { + couponNumber: '2', + couponDate: time('2026-10-01'), + fixDate: time('2026-09-09'), + payOneBond: money('10'), + }, + { + couponNumber: '3', + couponDate: time('2026-10-01'), + payOneBond: money('10'), + }, + { + couponNumber: '4', + couponDate: time('2026-10-01'), + fixDate: time('2026-09-29'), + }, ], - lastSuccessfulAt: '2026-09-10T00:00:00Z', - isStale: true, + position, + { from: '2021-09-10', to: '2027-09-10', today: '2026-09-10' }, + 'source-time', + false, + ); + expect(result.coupons[0].estimatedAmount).toEqual({ + value: '20', + currency: 'RUB', }); - tbank.getBondCoupons.mockResolvedValue([ - { - couponNumber: '1', - couponDate: time('2026-10-01'), - fixDate: time('2026-09-29'), - payOneBond: money('10'), - }, - { - couponNumber: '2', - couponDate: time('2026-10-01'), - fixDate: time('2026-09-09'), - payOneBond: money('10'), - }, - { - couponNumber: '3', - couponDate: time('2026-10-01'), - payOneBond: money('10'), - }, - { - couponNumber: '4', - couponDate: time('2026-10-01'), - fixDate: time('2026-09-29'), - }, - { - couponNumber: '5', - couponDate: time('2025-10-01'), - payOneBond: money('10'), - }, - ]); - const result = await income.coupons('user', 'uid'); expect( - result.coupons.find((c) => c.number === '1')?.estimatedAmount, - ).toEqual({ value: '20', currency: 'RUB' }); - expect( - result.coupons - .filter((c) => c.number !== '1') - .every((c) => c.estimatedAmount === null), + result.coupons.slice(1).every((row) => row.estimatedAmount === null), ).toBe(true); - expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull(); + expect(result.coupons[3].perBond).toBeNull(); expect(result.positionIsStale).toBe(true); - expect(await income.position('user', 'uid')).toMatchObject({ - quantity: '2', - currentValue: { value: '981.25' }, + expect(position.quantity).toBe('2'); + }); + it('preserves amortized nominal, ACI, percent quotes and annualized coupon yield', () => { + const values = { + instrument: bond, + price: { price: { units: '98', nano: 125000000 } }, + candle: { close: { units: '99' } }, + coupons: [ + { + couponDate: time('2026-10-10'), + payOneBond: money('30'), + couponPeriod: 90, + }, + ], + }; + expect(bondQuote(values, [], 'time')).toMatchObject({ + pricePercent: '98.125', + cleanPrice: { currency: 'RUB', value: '490.625' }, + dirtyPrice: { currency: 'RUB', value: '502.625' }, + changePercentagePoints: '-0.875', + }); + expect(bondMetrics(values, [], 'time')).toMatchObject({ + couponDate: '2026-10-10', + couponYieldPercent: '24.7983', + creditRating: null, + }); + }); + it.each(['unknown', 'currency', 'period', 'zero-price'])( + 'does not invent coupon yield with %s inputs', + (scenario) => { + const result = bondMetrics( + { + instrument: bond, + price: { price: { units: scenario === 'zero-price' ? '0' : '100' } }, + coupons: [ + { + couponDate: time('2026-10-10'), + payOneBond: + scenario === 'unknown' + ? undefined + : money('30', scenario === 'currency' ? 'usd' : 'rub'), + couponPeriod: scenario === 'period' ? 0 : 90, + }, + ], + }, + [], + 'time', + ); + expect(result.couponYieldPercent).toBeNull(); + }, + ); + it('keeps absent and mismatched ACI unknown and never substitutes risk level for a rating', () => { + expect( + bondQuote( + { + instrument: { ...bond, aciValue: money('1', 'usd') }, + price: { price: { units: '100' } }, + }, + [], + 'time', + ).dirtyPrice, + ).toBeNull(); + expect( + bondQuote( + { + instrument: { ...bond, aciValue: undefined }, + price: { price: { units: '100' } }, + }, + [], + 'time', + ).dirtyPrice, + ).toBeNull(); + expect( + bondMetrics( + { + instrument: { + ...bond, + riskLevel: 'RISK_LEVEL_LOW', + floatingCouponFlag: true, + }, + }, + ['yield'], + 'time', + ), + ).toMatchObject({ + creditRating: null, + couponType: 'COUPON_TYPE_FLOATING', + couponYieldPercent: null, }); }); }); diff --git a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts index cb6f3bd..da9bdf7 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts @@ -1,3 +1,4 @@ +import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; import { Controller, Get, @@ -14,15 +15,15 @@ import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; import { BondIncomeService } from '../application/bond-income.service'; import { BondMarketService } from '../application/bond-market.service'; import { - BondCandlesDto, + BondCandlesResourceDto, BondCandlesQueryDto, - BondCouponsDto, - BondDto, - BondMetricsDto, - BondPositionDto, - BondQuoteDto, + BondCouponsResourceDto, + BondResourceDto, + BondMetricsResourceDto, + BondPositionResourceDto, + BondQuoteResourceDto, BondSearchQueryDto, - BondSearchResultDto, + BondSearchResourceDto, BondTickerParamsDto, } from './bond.dto'; import { BondCatalogService } from '../application/bond-catalog.service'; @@ -39,68 +40,91 @@ export class BondController { private readonly income: BondIncomeService, ) {} @Get(':uid/metrics') - @ApiOkResponse({ type: BondMetricsDto }) + @ApiOkResponse({ type: BondMetricsResourceDto }) metrics( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.income.metrics(request.user.sub, uid); + return this.income.metrics(request.user.sub, uid, query.sync === 'observe'); } @Get() - @ApiOkResponse({ type: [BondSearchResultDto] }) + @ApiOkResponse({ type: BondSearchResourceDto }) search( @Req() request: Request & { user: AccessPayload }, @Query() query: BondSearchQueryDto, ) { - return this.bonds.search(request.user.sub, query.query); + return this.bonds.search( + request.user.sub, + query.query, + query.sync === 'observe', + ); } @Get('by-ticker/:ticker') - @ApiOkResponse({ type: BondDto }) + @ApiOkResponse({ type: BondResourceDto }) getByTicker( @Req() request: Request & { user: AccessPayload }, @Param() params: BondTickerParamsDto, + @Query() query: InvestmentReadQueryDto, ) { - return this.bonds.getByTicker(request.user.sub, params.ticker); + return this.bonds.getByTicker( + request.user.sub, + params.ticker, + query.sync === 'observe', + ); } @Get(':uid') - @ApiOkResponse({ type: BondDto }) + @ApiOkResponse({ type: BondResourceDto }) get( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.bonds.get(request.user.sub, uid); + return this.bonds.get(request.user.sub, uid, query.sync === 'observe'); } @Get(':uid/quote') - @ApiOkResponse({ type: BondQuoteDto }) + @ApiOkResponse({ type: BondQuoteResourceDto }) quote( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.market.quote(request.user.sub, uid); + return this.market.quote(request.user.sub, uid, query.sync === 'observe'); } @Get(':uid/candles') - @ApiOkResponse({ type: BondCandlesDto }) + @ApiOkResponse({ type: BondCandlesResourceDto }) candles( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, @Query() query: BondCandlesQueryDto, ) { - return this.market.candles(request.user.sub, uid, query.period); + return this.market.candles( + request.user.sub, + uid, + query.period, + query.sync === 'observe', + ); } @Get(':uid/position') - @ApiOkResponse({ type: BondPositionDto }) + @ApiOkResponse({ type: BondPositionResourceDto }) position( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.income.position(request.user.sub, uid); + return this.income.readPosition( + request.user.sub, + uid, + query.sync === 'observe', + ); } @Get(':uid/coupons') - @ApiOkResponse({ type: BondCouponsDto }) + @ApiOkResponse({ type: BondCouponsResourceDto }) coupons( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.income.coupons(request.user.sub, uid); + return this.income.coupons(request.user.sub, uid, query.sync === 'observe'); } } diff --git a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts index 3372402..009beb8 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts @@ -1,3 +1,4 @@ +import { InvestmentResourceMetaDto } from '../../../investment-resource.dto'; import { ApiProperty } from '@nestjs/swagger'; import { MoneyDto } from '../../../../../common/money.dto'; @@ -175,3 +176,51 @@ export class BondMetricsDto { @ApiProperty({ type: [String] }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } + +export class BondPositionResourceDto { + @ApiProperty({ type: BondPositionDto, nullable: true }) + declare data: BondPositionDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class BondSearchResourceDto { + @ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true }) + declare data: BondSearchResultDto[] | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class BondResourceDto { + @ApiProperty({ type: BondDto, nullable: true }) declare data: BondDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class BondQuoteResourceDto { + @ApiProperty({ type: BondQuoteDto, nullable: true }) + declare data: BondQuoteDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class BondMetricsResourceDto { + @ApiProperty({ type: BondMetricsDto, nullable: true }) + declare data: BondMetricsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class BondCandlesResourceDto { + @ApiProperty({ type: BondCandlesDto, nullable: true }) + declare data: BondCandlesDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class BondCouponsResourceDto { + @ApiProperty({ type: BondCouponsDto, nullable: true }) + declare data: BondCouponsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} diff --git a/src/modules/investments/instruments/domain/candle-coverage.spec.ts b/src/modules/investments/instruments/domain/candle-coverage.spec.ts new file mode 100644 index 0000000..549051a --- /dev/null +++ b/src/modules/investments/instruments/domain/candle-coverage.spec.ts @@ -0,0 +1,73 @@ +import { + candleGaps, + candleWindows, + confirmedCandleCoverage, +} from './candle-coverage'; +const date = (value: string) => new Date(value); +describe('source-confirmed daily candle coverage', () => { + it('retains closed empty coverage while yesterday incomplete and open empty ranges require rereading after midnight', () => { + const range = { from: date('2026-09-20Z'), to: date('2026-09-26Z') }; + const now = date('2026-09-25T23:59:50Z'); + const coverage = confirmedCandleCoverage( + range, + [ + { + time: '2026-09-24T00:00:00Z', + close: '1.000000001', + isComplete: false, + }, + ], + now, + ); + expect( + coverage.map((row) => [ + row.from.toISOString().slice(0, 10), + row.to.toISOString().slice(0, 10), + row.isComplete, + ]), + ).toEqual([ + ['2026-09-20', '2026-09-24', true], + ['2026-09-24', '2026-09-25', false], + ['2026-09-25', '2026-09-26', false], + ]); + expect(candleGaps(range, coverage, date('2026-09-25T23:59:59Z'))).toEqual( + [], + ); + expect(candleGaps(range, coverage, date('2026-09-26T00:00:01Z'))).toEqual([ + { from: date('2026-09-24Z'), to: range.to }, + ]); + expect( + candleGaps( + range, + confirmedCandleCoverage(range, [], date('2026-09-27Z')), + date('2030-01-01Z'), + ), + ).toEqual([]); + }); + it('expires the mutable empty current day at exactly one minute', () => { + const range = { from: date('2026-09-25Z'), to: date('2026-09-26Z') }; + const covered = confirmedCandleCoverage( + range, + [], + date('2026-09-25T08:00:00Z'), + ); + expect(candleGaps(range, covered, date('2026-09-25T08:00:59Z'))).toEqual( + [], + ); + expect(candleGaps(range, covered, date('2026-09-25T08:01:00Z'))).toEqual([ + range, + ]); + }); + it('splits leap-year history into contiguous windows of at most 365 days', () => { + const range = { from: date('2020-01-01Z'), to: date('2026-09-25Z') }; + const windows = candleWindows(range); + expect(windows[0].from).toEqual(range.from); + expect(windows.at(-1)?.to).toEqual(range.to); + windows.forEach((window, index) => { + expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual( + 365 * 86400000, + ); + if (index) expect(window.from).toEqual(windows[index - 1].to); + }); + }); +}); diff --git a/src/modules/investments/instruments/domain/candle-coverage.ts b/src/modules/investments/instruments/domain/candle-coverage.ts new file mode 100644 index 0000000..d4302c9 --- /dev/null +++ b/src/modules/investments/instruments/domain/candle-coverage.ts @@ -0,0 +1,90 @@ +import { MILLISECONDS_PER_DAY } from '../../../../common/time'; + +export type CandleRange = { from: Date; to: Date }; +export type CandleCoverage = CandleRange & { + isComplete: boolean; + fetchedAt: Date; +}; +export type SavedCandle = { + time: string; + close: string; + isComplete: boolean; +}; +const windowDays = 365; +const tailFreshForMs = 60_000; +export function utcDay(value: Date): Date { + return new Date( + Math.floor(value.valueOf() / MILLISECONDS_PER_DAY) * MILLISECONDS_PER_DAY, + ); +} + +// A clock transition never turns an unconfirmed range into closed coverage. +export function candleGaps( + range: CandleRange, + coverage: CandleCoverage[], + now: Date, +): CandleRange[] { + let gaps = [range]; + for (const covered of coverage) { + if ( + !covered.isComplete && + (now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs || + utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf()) + ) + continue; + gaps = gaps.flatMap((gap) => { + if (covered.to <= gap.from || covered.from >= gap.to) return [gap]; + return [ + ...(covered.from > gap.from + ? [{ from: gap.from, to: covered.from }] + : []), + ...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []), + ]; + }); + } + return gaps.filter((gap) => gap.from < gap.to); +} + +export function candleWindows(range: CandleRange): CandleRange[] { + const windows: CandleRange[] = []; + for (let start = range.from.valueOf(); start < range.to.valueOf();) { + const end = Math.min( + start + windowDays * MILLISECONDS_PER_DAY, + range.to.valueOf(), + ); + windows.push({ from: new Date(start), to: new Date(end) }); + start = end; + } + return windows; +} + +export function confirmedCandleCoverage( + range: CandleRange, + candles: SavedCandle[], + fetchedAt: Date, +): CandleCoverage[] { + const boundaries = new Set([range.from.valueOf(), range.to.valueOf()]); + const midnight = utcDay(fetchedAt).valueOf(); + const incompleteDays = new Set( + candles + .filter((candle) => !candle.isComplete) + .map((candle) => utcDay(new Date(candle.time)).valueOf()), + ); + for (const boundary of [ + midnight, + ...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]), + ]) + if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) + boundaries.add(boundary); + const sorted = [...boundaries].sort((a, b) => a - b); + return sorted.slice(1).map((to, index) => { + const from = sorted[index]; + return { + from: new Date(from), + to: new Date(to), + fetchedAt, + isComplete: + to <= midnight && !incompleteDays.has(utcDay(new Date(from)).valueOf()), + }; + }); +} diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts new file mode 100644 index 0000000..5b205bb --- /dev/null +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -0,0 +1,306 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { createHash } from 'node:crypto'; +import type { Prisma } from '../../../../../generated/prisma/client'; +import { PrismaService } from '../../../../prisma/prisma.service'; +import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; +import { + confirmedCandleCoverage, + type CandleRange, + type SavedCandle, +} from '../domain/candle-coverage'; + +const provider = 'tbank'; +const interval = 'day'; +type CandleIntent = { + instrumentType?: 'share' | 'bond'; + uid: string; + ranges: CandleRange[]; + period?: string; + now?: string; + force?: boolean; +}; +@Injectable() +export class CandleRepository { + constructor(private readonly prisma: PrismaService) {} + + read(uid: string, range: CandleRange) { + const identity = { provider, instrumentUid: uid, interval }; + return this.prisma.$transaction(async (tx) => { + const candles = await tx.tbankCandle.findMany({ + where: { ...identity, time: { gte: range.from, lt: range.to } }, + orderBy: { time: 'asc' }, + }); + const coverage = await tx.tbankCandleCoverage.findMany({ + where: { ...identity, from: { lt: range.to }, to: { gt: range.from } }, + orderBy: [{ from: 'asc' }, { to: 'asc' }], + }); + const data = candles.map((row) => ({ + time: row.time.toISOString(), + close: row.close, + isComplete: row.isComplete, + })); + return { + data, + coverage, + dataRevision: createHash('sha256') + .update(JSON.stringify(data)) + .digest('base64url'), + }; + }); + } + + coverage(uids: string[]) { + return this.prisma.tbankCandleCoverage.findMany({ + where: { provider, instrumentUid: { in: uids }, interval }, + }); + } + + reserve( + owner: { userId: string; revision: string }, + scopeKey: string, + intent: CandleIntent, + runId: string, + active: ReadonlySet, + priority = 1, + ) { + return this.prisma.$transaction(async (tx) => { + const current = await tx.tbankConnection.updateMany({ + where: { ...owner, invalidToken: false }, + data: { membershipRevision: { increment: 0 } }, + }); + if (!current.count) return null; + const scope = { ...owner, scopeKey }; + const previous = await tx.tbankSyncWork.findUnique({ + where: { userId_revision_scopeKey: scope }, + }); + if ( + previous?.state === 'running' && + previous.processRunId === runId && + active.has(previous.attemptId) + ) + return { work: previous, launch: false }; + if (previous) + await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); + const data = { + attemptId: randomUUID(), + processRunId: runId, + state: 'running', + priority, + intent: JSON.parse(JSON.stringify(intent)) as Prisma.InputJsonObject, + progress: {}, + errorCode: null, + }; + const work = await tx.tbankSyncWork.upsert({ + where: { userId_revision_scopeKey: scope }, + create: { ...scope, ...data, id: randomUUID() }, + update: data, + }); + return { work, launch: true }; + }); + } + + work(userId: string, revision: string, scopeKey: string) { + return this.prisma.tbankSyncWork.findUnique({ + where: { userId_revision_scopeKey: { userId, revision, scopeKey } }, + }); + } + + stage( + attempt: TbankSyncAttempt, + range: CandleRange, + candles: SavedCandle[], + fetchedAt: Date, + ) { + // Reject malformed financial input instead of publishing a false empty range. + for (const candle of candles) + if ( + !Number.isFinite(Date.parse(candle.time)) || + !/^-?\d+(?:\.\d+)?$/.test(candle.close) + ) + throw new Error('Invalid candle'); + const key = `${range.from.toISOString()}/${range.to.toISOString()}`; + const rows = [ + ...new Map( + candles + .filter( + (candle) => + new Date(candle.time) >= range.from && + new Date(candle.time) < range.to, + ) + .map((candle) => [candle.time, candle]), + ).values(), + ]; + return this.prisma.$transaction(async (tx) => { + const valid = await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { progress: { lastWindow: key } }, + }); + if (!valid.count) return false; + const identity = { + workId: attempt.id, + attemptId: attempt.attemptId, + sourceKey: 'candles', + itemKey: key, + }; + const payload = JSON.parse( + JSON.stringify({ range, candles: rows, fetchedAt }), + ) as Prisma.InputJsonObject; + await tx.tbankSyncStage.upsert({ + where: { workId_attemptId_sourceKey_itemKey: identity }, + create: { ...identity, payload }, + update: { payload }, + }); + return true; + }); + } + + publish( + attempt: TbankSyncAttempt, + intent: CandleIntent, + completeWork = true, + ) { + return this.prisma.$transaction(async (tx) => { + const claimed = await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { + ...(completeWork ? { state: 'complete' } : {}), + errorCode: null, + }, + }); + if (!claimed.count) return false; + const staged = await tx.tbankSyncStage.findMany({ + where: { + workId: attempt.id, + attemptId: attempt.attemptId, + sourceKey: 'candles', + }, + }); + if ( + intent.ranges.some( + (range) => + !staged.some( + (row) => + row.itemKey === + `${range.from.toISOString()}/${range.to.toISOString()}`, + ), + ) + ) + throw new Error('Incomplete candle reconciliation'); + const identity = { provider, instrumentUid: intent.uid, interval }; + for (const range of intent.ranges) { + const row = staged.find( + (entry) => + entry.itemKey === + `${range.from.toISOString()}/${range.to.toISOString()}`, + ); + if (!row) throw new Error('Incomplete candle reconciliation'); + const payload = row.payload as { + candles: SavedCandle[]; + fetchedAt: string; + }; + const fetchedAt = new Date(payload.fetchedAt); + const old = await tx.tbankCandleCoverage.findMany({ + where: { + ...identity, + from: { lt: range.to }, + to: { gt: range.from }, + }, + }); + await tx.tbankCandleCoverage.deleteMany({ + where: { + ...identity, + from: { lt: range.to }, + to: { gt: range.from }, + }, + }); + // Preserve coverage outside the requested reconciliation boundaries. + for (const previous of old) { + for (const part of [ + ...(previous.from < range.from + ? [{ from: previous.from, to: range.from }] + : []), + ...(previous.to > range.to + ? [{ from: range.to, to: previous.to }] + : []), + ]) + await tx.tbankCandleCoverage.create({ + data: { ...previous, ...part }, + }); + } + await tx.tbankCandle.deleteMany({ + where: { ...identity, time: { gte: range.from, lt: range.to } }, + }); + for (const candle of payload.candles) + await tx.tbankCandle.create({ + data: { + ...identity, + time: new Date(candle.time), + close: candle.close, + isComplete: candle.isComplete, + payload: candle, + fetchedAt, + }, + }); + for (const covered of confirmedCandleCoverage( + range, + payload.candles, + fetchedAt, + )) + await tx.tbankCandleCoverage.create({ + data: { ...identity, ...covered }, + }); + } + await tx.tbankSyncStage.deleteMany({ + where: { workId: attempt.id, attemptId: attempt.attemptId }, + }); + return true; + }); + } + + completeObserver(attempt: TbankSyncAttempt, initiator: TbankSyncAttempt) { + return this.prisma.$transaction(async (tx) => { + const origin = await tx.tbankSyncWork.findFirst({ + where: { + ...initiator, + state: 'complete', + connection: { invalidToken: false }, + }, + }); + if (!origin) return false; + const result = await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { state: 'complete', errorCode: null }, + }); + return result.count > 0; + }); + } + + fail(attempt: TbankSyncAttempt, errorCode: string) { + return this.prisma.$transaction(async (tx) => { + await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { state: 'failed', errorCode }, + }); + await tx.tbankSyncStage.deleteMany({ + where: { workId: attempt.id, attemptId: attempt.attemptId }, + }); + }); + } +} diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts new file mode 100644 index 0000000..b7bc444 --- /dev/null +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -0,0 +1,480 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import type { Prisma } from '../../../../../generated/prisma/client'; +import { PrismaService } from '../../../../prisma/prisma.service'; +import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; +import type { + TbankBond, + TbankShare, +} from '../../../../integrations/tbank/tbank.api'; + +export type CatalogQuery = { + instrumentType: 'share' | 'bond'; + query: string; + exactTicker?: boolean; +}; +export type CatalogSource = { + instrumentType: 'share' | 'bond'; + uid: string; + kind: + | 'detail' + | 'last-price' + | 'bond-current' + | 'description' + | 'fundamentals' + | 'trading-status' + | 'comparison' + | 'coupons' + | 'yield' + | 'rating' + | 'dividends' + | 'bond-events'; + rangeKey?: string; + ticker?: string; +}; +export type CatalogWork = CatalogQuery | CatalogSource; +export type CatalogInstrument = (TbankShare | TbankBond) & { uid: string }; +const provider = 'tbank'; +export function catalogQuery(input: CatalogQuery): CatalogQuery { + return { + instrumentType: input.instrumentType, + query: input.query.trim().toUpperCase(), + ...(input.exactTicker ? { exactTicker: true } : {}), + }; +} +export function catalogScope(input: CatalogWork) { + if ('uid' in input) + return `catalog-source:${JSON.stringify([ + input.kind, + input.kind === 'detail' ? input.instrumentType : null, + input.rangeKey ?? '', + input.uid, + ])}`; + return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`; +} + +@Injectable() +export class CatalogDiscoveryRepository { + constructor(private readonly prisma: PrismaService) {} + + read(input: CatalogQuery) { + return this.prisma.$transaction(async (tx) => { + const alias = input.exactTicker + ? await tx.tbankInstrumentLookup.findUnique({ + where: { + provider_identifierType_identifier: { + provider, + identifierType: `${input.instrumentType}-ticker`, + identifier: input.query, + }, + }, + }) + : null; + const discovery = input.exactTicker + ? alias + ? { + instrumentUids: alias.matchedUids, + fetchedAt: alias.fetchedAt, + dataRevision: alias.dataRevision, + } + : null + : await tx.tbankCatalogDiscovery.findUnique({ + where: { + provider_instrumentType_query: { + provider, + instrumentType: input.instrumentType, + query: input.query, + }, + }, + }); + if (!discovery) return { data: null, fetchedAt: null, dataRevision: '0' }; + const uids = discovery.instrumentUids as string[]; + const rows = await tx.tbankInstrumentSource.findMany({ + where: { + provider, + instrumentUid: { in: uids }, + source: provider, + kind: 'catalog', + rangeKey: input.instrumentType, + }, + }); + const byUid = new Map(rows.map((row) => [row.instrumentUid, row])); + // A damaged/missing dependency is unknown, never a confirmed empty search. + if (uids.some((uid) => !byUid.has(uid))) + return { + data: null, + fetchedAt: null, + dataRevision: String(discovery.dataRevision), + }; + return { + data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument), + fetchedAt: discovery.fetchedAt, + dataRevision: [ + discovery.dataRevision, + ...uids.map((uid) => byUid.get(uid)?.dataRevision), + ].join('/'), + }; + }); + } + + readSources(inputs: CatalogSource[]) { + return this.prisma.tbankInstrumentSource.findMany({ + where: { + provider, + OR: inputs.map((input) => ({ + instrumentUid: input.uid, + kind: input.kind, + source: sourceProvider(input), + rangeKey: + input.kind === 'detail' + ? input.instrumentType + : (input.rangeKey ?? ''), + })), + }, + }); + } + + publishSource( + attempt: TbankSyncAttempt, + input: CatalogSource, + value: object, + now: Date, + initiator: TbankSyncAttempt = attempt, + ) { + return this.prisma.$transaction(async (tx) => { + const origin = await tx.tbankSyncWork.updateMany({ + where: { + ...initiator, + state: { in: ['running', 'complete'] }, + connection: { invalidToken: false }, + }, + data: { progress: {} }, + }); + if (!origin.count) return false; + const claimed = await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { state: 'complete', progress: {} }, + }); + if (!claimed.count) return false; + const payload = JSON.parse( + JSON.stringify(value), + ) as Prisma.InputJsonObject; + const identity = { provider, uid: input.uid }; + await tx.tbankInstrument.upsert({ + where: { provider_uid: identity }, + update: {}, + create: { + ...identity, + metadata: {}, + instrumentType: input.instrumentType, + fetchedAt: now, + }, + }); + const instrument = ( + value as { instrument?: TbankShare | TbankBond | null } + ).instrument; + if ( + (input.kind === 'detail' || input.kind === 'bond-current') && + instrument && + (instrument.name?.trim() || instrument.ticker?.trim()) + ) { + const previous = await tx.tbankInstrument.findUniqueOrThrow({ + where: { provider_uid: identity }, + }); + const metadata = JSON.parse( + JSON.stringify({ + ...instrument, + instrumentType: input.instrumentType, + }), + ) as Prisma.InputJsonObject; + await tx.tbankInstrument.update({ + where: { provider_uid: identity }, + data: { + metadata, + instrumentType: input.instrumentType, + figi: instrument.figi ?? null, + ticker: instrument.ticker ?? null, + fetchedAt: now, + dataRevision: { + increment: isDeepStrictEqual(previous.metadata, metadata) ? 0 : 1, + }, + }, + }); + for (const [identifierType, identifier] of [ + ['uid', input.uid], + ['figi', instrument.figi], + ] as const) { + if (!identifier) continue; + const alias = { provider, identifierType, identifier }; + const previousAlias = await tx.tbankInstrumentLookup.findUnique({ + where: { provider_identifierType_identifier: alias }, + }); + const matchedUids = [input.uid]; + await tx.tbankInstrumentLookup.upsert({ + where: { provider_identifierType_identifier: alias }, + create: { ...alias, matchedUids, fetchedAt: now }, + update: { + matchedUids, + fetchedAt: now, + dataRevision: { + increment: isDeepStrictEqual( + previousAlias?.matchedUids, + matchedUids, + ) + ? 0 + : 1, + }, + }, + }); + } + } + const key = { + provider, + instrumentUid: input.uid, + source: sourceProvider(input), + kind: input.kind, + rangeKey: + input.kind === 'detail' + ? input.instrumentType + : (input.rangeKey ?? ''), + }; + const previous = await tx.tbankInstrumentSource.findUnique({ + where: { provider_instrumentUid_source_kind_rangeKey: key }, + }); + await tx.tbankInstrumentSource.upsert({ + where: { provider_instrumentUid_source_kind_rangeKey: key }, + create: { ...key, payload, fetchedAt: now }, + update: { + payload, + fetchedAt: now, + dataRevision: { + increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1, + }, + }, + }); + return true; + }); + } + + reserve( + owner: { userId: string; revision: string }, + input: CatalogWork, + runId: string, + active: ReadonlySet, + ) { + return this.prisma.$transaction(async (tx) => { + const current = await tx.tbankConnection.updateMany({ + where: { ...owner, invalidToken: false }, + data: { membershipRevision: { increment: 0 } }, + }); + if (!current.count) return null; + const scope = { ...owner, scopeKey: catalogScope(input) }; + const previous = await tx.tbankSyncWork.findUnique({ + where: { userId_revision_scopeKey: scope }, + }); + if ( + previous?.state === 'running' && + previous.processRunId === runId && + active.has(previous.attemptId) + ) + return { work: previous, launch: false }; + const data = { + attemptId: randomUUID(), + processRunId: runId, + state: 'running', + priority: 1, + intent: { source: 'catalog', ...input }, + progress: {}, + errorCode: null, + }; + const work = await tx.tbankSyncWork.upsert({ + where: { userId_revision_scopeKey: scope }, + create: { ...scope, ...data, id: randomUUID() }, + update: data, + }); + return { work, launch: true }; + }); + } + + workMany(userId: string, revision: string, inputs: CatalogWork[]) { + return this.prisma.tbankSyncWork.findMany({ + where: { userId, revision, scopeKey: { in: inputs.map(catalogScope) } }, + select: { scopeKey: true, id: true, state: true, errorCode: true }, + }); + } + + work(userId: string, revision: string, input: CatalogWork) { + return this.prisma.tbankSyncWork.findUnique({ + where: { + userId_revision_scopeKey: { + userId, + revision, + scopeKey: catalogScope(input), + }, + }, + select: { id: true, state: true, errorCode: true }, + }); + } + + publish( + attempt: TbankSyncAttempt, + input: CatalogQuery, + instruments: CatalogInstrument[], + now: Date, + ) { + return this.prisma.$transaction(async (tx) => { + const claimed = await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { state: 'complete', progress: {} }, + }); + if (!claimed.count) return false; + for (const instrument of instruments) { + // Discovery can create a minimal identity, but cannot overwrite verified metadata. + const identity = { provider, uid: instrument.uid }; + const payload = JSON.parse( + JSON.stringify({ + uid: instrument.uid, + figi: instrument.figi, + ticker: instrument.ticker, + name: instrument.name, + isin: instrument.isin, + classCode: instrument.classCode, + instrumentKind: instrument.instrumentKind, + apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, + currency: instrument.currency, + brand: instrument.brand + ? { logoName: instrument.brand.logoName } + : undefined, + }), + ) as Prisma.InputJsonObject; + await tx.tbankInstrument.upsert({ + where: { provider_uid: identity }, + update: {}, + create: { + ...identity, + metadata: payload, + instrumentType: input.instrumentType, + figi: instrument.figi, + ticker: instrument.ticker, + fetchedAt: now, + }, + }); + const key = { + provider, + instrumentUid: instrument.uid, + source: provider, + kind: 'catalog', + rangeKey: input.instrumentType, + }; + const previous = await tx.tbankInstrumentSource.findUnique({ + where: { provider_instrumentUid_source_kind_rangeKey: key }, + }); + await tx.tbankInstrumentSource.upsert({ + where: { provider_instrumentUid_source_kind_rangeKey: key }, + create: { ...key, payload, fetchedAt: now }, + update: { + payload, + fetchedAt: now, + dataRevision: { + increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1, + }, + }, + }); + } + const instrumentUids = instruments.map((instrument) => instrument.uid); + if (input.exactTicker) { + const key = { + provider, + identifierType: `${input.instrumentType}-ticker`, + identifier: input.query, + }; + const previous = await tx.tbankInstrumentLookup.findUnique({ + where: { provider_identifierType_identifier: key }, + }); + await tx.tbankInstrumentLookup.upsert({ + where: { provider_identifierType_identifier: key }, + create: { ...key, matchedUids: instrumentUids, fetchedAt: now }, + update: { + matchedUids: instrumentUids, + fetchedAt: now, + dataRevision: { + increment: isDeepStrictEqual( + previous?.matchedUids, + instrumentUids, + ) + ? 0 + : 1, + }, + }, + }); + return true; + } + const key = { + provider, + instrumentType: input.instrumentType, + query: input.query, + }; + const previous = await tx.tbankCatalogDiscovery.findUnique({ + where: { provider_instrumentType_query: key }, + }); + await tx.tbankCatalogDiscovery.upsert({ + where: { provider_instrumentType_query: key }, + create: { ...key, instrumentUids, fetchedAt: now }, + update: { + instrumentUids, + fetchedAt: now, + dataRevision: { + increment: isDeepStrictEqual( + previous?.instrumentUids, + instrumentUids, + ) + ? 0 + : 1, + }, + }, + }); + return true; + }); + } + + finish(attempt: TbankSyncAttempt, success: boolean) { + return this.prisma.$transaction(async (tx) => { + await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: true }, + }, + data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + }); + await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { + state: success ? 'complete' : 'failed', + errorCode: success ? null : 'TBANK_UNAVAILABLE', + }, + }); + }); + } +} + +function sourceProvider(input: CatalogSource) { + return input.kind === 'yield' || input.kind === 'bond-events' + ? 'moex' + : input.kind === 'rating' + ? 'ncr' + : 'tbank'; +} diff --git a/src/modules/investments/instruments/instrument-income.api.ts b/src/modules/investments/instruments/instrument-income.api.ts new file mode 100644 index 0000000..b717fd0 --- /dev/null +++ b/src/modules/investments/instruments/instrument-income.api.ts @@ -0,0 +1,2 @@ +export { StockIncomeService } from './stocks/application/stock-income.service'; +export { BondIncomeService } from './bonds/application/bond-income.service'; diff --git a/src/modules/investments/instruments/instrument-sources.module.ts b/src/modules/investments/instruments/instrument-sources.module.ts new file mode 100644 index 0000000..24a85ef --- /dev/null +++ b/src/modules/investments/instruments/instrument-sources.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { MoexModule } from '../../../integrations/moex/moex.module'; +import { NcrModule } from '../../../integrations/ncr/ncr.module'; +import { TbankModule } from '../../../integrations/tbank/tbank.module'; +import { CandleService } from './application/candle.service'; +import { CandleRepository } from './infrastructure/candle.repository'; +import { CatalogEnrichmentService } from './application/catalog-enrichment.service'; +import { CatalogDiscoveryService } from './application/catalog-discovery.service'; +import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository'; + +@Module({ + imports: [TbankModule, MoexModule, NcrModule], + providers: [ + CandleService, + CandleRepository, + CatalogEnrichmentService, + CatalogDiscoveryService, + CatalogDiscoveryRepository, + ], + exports: [CandleService, CatalogEnrichmentService, CatalogDiscoveryService], +}) +export class InstrumentSourcesModule {} diff --git a/src/modules/investments/instruments/instruments.api.ts b/src/modules/investments/instruments/instruments.api.ts new file mode 100644 index 0000000..bc88c67 --- /dev/null +++ b/src/modules/investments/instruments/instruments.api.ts @@ -0,0 +1,5 @@ +export { CandleService } from './application/candle.service'; +export { CatalogEnrichmentService } from './application/catalog-enrichment.service'; +export type { CatalogPayload } from './application/catalog-enrichment.service'; + +export { CatalogDiscoveryService } from './application/catalog-discovery.service'; diff --git a/src/modules/investments/instruments/instruments.module.ts b/src/modules/investments/instruments/instruments.module.ts index e930cae..8eb5280 100644 --- a/src/modules/investments/instruments/instruments.module.ts +++ b/src/modules/investments/instruments/instruments.module.ts @@ -1,6 +1,5 @@ +import { InstrumentSourcesModule } from './instrument-sources.module'; import { Module } from '@nestjs/common'; -import { MoexModule } from '../../../integrations/moex/moex.module'; -import { NcrModule } from '../../../integrations/ncr/ncr.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { PortfolioModule } from '../portfolio/portfolio.module'; @@ -15,8 +14,9 @@ import { StockController } from './stocks/presentation/stock.controller'; import { StockCatalogService } from './stocks/application/stock-catalog.service'; @Module({ - imports: [AuthModule, TbankModule, MoexModule, NcrModule, PortfolioModule], + imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule], controllers: [StockController, BondController], + exports: [StockIncomeService, BondIncomeService], providers: [ BondIncomeService, BondMarketService, diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts index 5e2ff33..8c05cdb 100644 --- a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -1,50 +1,42 @@ import { Injectable } from '@nestjs/common'; import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api'; -import { TbankClient } from '../../../../../integrations/tbank/tbank.api'; -import { optional } from '../../application/instrument-market'; -import { StockCompanyDto, StockMetricDto } from '../presentation/stock.dto'; - -import { StockCatalogService } from './stock-catalog.service'; +import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; +import { + StockCompanyResourceDto, + StockMetricDto, +} from '../presentation/stock.dto'; @Injectable() export class GetStockCompanyUseCase { - constructor( - private readonly catalog: StockCatalogService, - private readonly tbank: TbankClient, - ) {} - execute(userId: string, uid: string): Promise { - return this.catalog.cached(userId, `company:${uid}`, async (token) => { - const share = await this.catalog.share(token, uid); - const unavailable: string[] = []; - const assetUid = share.assetUid; - if (!assetUid) - return { - description: null, - metrics: [], - unavailable, - observedAt: new Date().toISOString(), - }; - const [description, fundamentals] = await Promise.all([ - optional( - () => this.tbank.getAssetDescription(token, assetUid), - 'description', - unavailable, - ), - optional( - () => this.tbank.getFundamentals(token, assetUid), - 'fundamentals', - unavailable, - ), - ]); - return { - description, - metrics: metrics(fundamentals, share.sector), - unavailable, - observedAt: new Date().toISOString(), - }; - }); + constructor(private readonly sources: CatalogEnrichmentService) {} + async execute( + userId: string, + uid: string, + observe = false, + ): Promise { + const result = await this.sources.section( + userId, + 'share', + uid, + 'company', + observe, + ); + return { + data: result.values.instrument + ? { + description: result.values.description ?? null, + metrics: metrics( + result.values.fundamentals ?? null, + result.values.instrument.sector, + ), + unavailable: result.unavailable, + observedAt: result.observedAt, + } + : null, + meta: result.meta, + }; } } -function metrics( +export function metrics( values: TbankFundamentals | null, sector?: string, ): StockMetricDto[] { diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index a41aa84..867182c 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -1,185 +1,87 @@ -import { - ConflictException, - Injectable, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; -import { LRUCache } from 'lru-cache'; +import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; +import { Injectable } from '@nestjs/common'; import { formatDecimal } from '../../../../../common/decimal'; import type { TbankShare } from '../../../../../integrations/tbank/tbank.api'; -import { - TbankAccessService, - TbankClient, -} from '../../../../../integrations/tbank/tbank.api'; import { decimal } from '../../../../../integrations/tbank/decimal'; -import { optional, timestamp } from '../../application/instrument-market'; -import { StockDto, StockSearchResultDto } from '../presentation/stock.dto'; - -const SEARCH_RESULTS_LIMIT = 30; +import { timestamp } from '../../application/instrument-market'; +import { + StockDto, + StockResourceDto, + StockSearchResourceDto, +} from '../presentation/stock.dto'; type IdentifiedShare = TbankShare & { uid: string }; @Injectable() export class StockCatalogService { - private readonly cache = new LRUCache>({ - max: 300, - ttl: 30_000, - }); - constructor( - private readonly access: TbankAccessService, - private readonly tbank: TbankClient, - ) {} - search(userId: string, query: string): Promise { - return this.cached(userId, `search:${query}`, async (token) => { - const found = await this.tbank.findShares(token, query); - const shares = selectShares(found, query).slice(0, SEARCH_RESULTS_LIMIT); - if (!shares.length) return []; - // One market-data request for the list; failed enrichment must not hide a catalog result. - const [details, prices] = await Promise.all([ - Promise.all( - shares.map( - async (share) => - (await optional( - () => - this.get(userId, share.uid).catch((error: unknown) => { - if (error instanceof NotFoundException) return stock(share); - throw error; - }), - 'details', - [], - )) ?? stock(share), - ), - ), - optional( - () => - this.tbank.getLastPrices( - token, - shares.map((share) => share.uid), - ), - 'prices', - [], - ), - ]); - return details.map((detail, index) => { - const short = stock(shares[index]); - const last = prices?.find( - (price) => price.instrumentUid === detail.instrumentUid, - ); - return { - ...detail, - classCode: detail.classCode ?? short.classCode, - isin: detail.isin ?? short.isin, - apiTradeAvailable: short.apiTradeAvailable, - price: - last?.price && detail.currency - ? { - currency: detail.currency, - value: formatDecimal(decimal(last.price)), - } - : null, - priceAt: timestamp(last?.time), - }; - }); - }); - } - get(userId: string, uid: string): Promise { - return this.cached(userId, `share:${uid}`, async (token) => { - const result = stock(await this.share(token, uid)); - if ( - !result.ticker || - !/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker) - ) - return result; - try { - const match = await this.tickerMatch(userId, result.ticker); - if (match.uid === uid) result.slug = result.ticker.toUpperCase(); - } catch (error) { - // A readable alias is optional for an exact UID URL, never a reason to substitute another listing. - if ( - !(error instanceof NotFoundException) && - !(error instanceof ConflictException) && - !(error instanceof ServiceUnavailableException) - ) - throw error; - } - return result; - }); - } - async getByTicker(userId: string, ticker: string): Promise { - const match = await this.tickerMatch(userId, ticker); + constructor(private readonly discovery: CatalogDiscoveryService) {} + async search( + userId: string, + query: string, + observe = false, + ): Promise { + const result = await this.discovery.search( + userId, + { instrumentType: 'share', query }, + observe, + ); return { - ...(await this.get(userId, match.uid)), - slug: ticker.trim().toUpperCase(), + ...result, + data: + result.data?.map((item) => { + const short = stock(item); + const detail = item.detail + ? stock({ ...item.detail, uid: item.uid }) + : short; + const last = item.price; + return { + ...detail, + classCode: detail.classCode ?? short.classCode, + isin: detail.isin ?? short.isin, + apiTradeAvailable: short.apiTradeAvailable, + price: + last?.price && detail.currency + ? { + currency: detail.currency, + value: formatDecimal(decimal(last.price)), + } + : null, + priceAt: timestamp(last?.time), + }; + }) ?? null, }; } - private tickerMatch( + async get( + userId: string, + uid: string, + observe = false, + ): Promise { + const result = await this.discovery.detail(userId, 'share', uid, observe); + return { + ...result, + data: result.data + ? { ...stock(result.data), slug: result.data.slug } + : null, + }; + } + async getByTicker( userId: string, ticker: string, - ): Promise { - const normalized = ticker.trim().toUpperCase(); - return this.cached(userId, `ticker:${normalized}`, async (token) => { - const found = await this.tbank.findShares(token, normalized); - const matches = selectShares( - found.filter( - (item) => item.ticker?.trim().toUpperCase() === normalized, - ), - normalized, - ); - if (!matches.length) throw new NotFoundException(); - if (matches.length > 1) - throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' }); - return matches[0]; - }); + observe = false, + ): Promise { + const result = await this.discovery.byTicker( + userId, + 'share', + ticker, + observe, + ); + return { + ...result, + data: result.data + ? { ...stock(result.data), slug: result.data.slug } + : null, + }; } - async share(token: string, uid: string): Promise { - const share = await this.tbank.getShare(token, uid); - if (!share || share.uid !== uid) throw new NotFoundException(); - return { ...share, uid }; - } - cached( - userId: string, - key: string, - operation: (token: string) => Promise, - ): Promise { - return this.access.withConnection(userId, ({ token, generation }) => { - const cacheKey = JSON.stringify([userId, generation, key]); - const cached = this.cache.get(cacheKey); - if (cached) return cached as Promise; - const pending = operation(token); - this.cache.set(cacheKey, pending); - return pending.catch((error) => { - if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey); - throw error; - }); - }); - } -} - -function selectShares(found: TbankShare[], query: string): IdentifiedShare[] { - const seen = new Set(); - return found - .filter( - (share): share is IdentifiedShare => - Boolean(share.uid) && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE', - ) - .sort( - (a, b) => - Number(b.apiTradeAvailableFlag === true) - - Number(a.apiTradeAvailableFlag === true) || - Number(b.ticker?.toUpperCase() === query.toUpperCase()) - - Number(a.ticker?.toUpperCase() === query.toUpperCase()) || - Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') || - a.uid.localeCompare(b.uid), - ) - .filter((share) => { - // A missing ISIN must not collapse unrelated catalog instruments. - const key = share.isin?.trim() - ? `isin:${share.isin.trim().toUpperCase()}` - : `uid:${share.uid}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); } function stock(share: IdentifiedShare): StockDto { diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index a2432f0..a0754a7 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -1,109 +1,165 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { decimalFromString, formatDecimal, multiply, } from '../../../../../common/decimal'; -import { TbankClient } from '../../../../../integrations/tbank/tbank.api'; -import { decimal } from '../../../../../integrations/tbank/decimal'; -import { PortfolioService } from '../../../portfolio/portfolio.api'; import { - moscowDay, - optional, - timestamp, -} from '../../application/instrument-market'; -import { stockPosition } from './stock-position'; + TbankAccessService, + type TbankDividend, +} from '../../../../../integrations/tbank/tbank.api'; +import { + PortfolioService, + ForecastService, +} from '../../../portfolio/portfolio.api'; +import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; +import { moscowDay, timestamp } from '../../application/instrument-market'; +import { instrumentPosition } from '../../application/instrument-position'; import { - StockDividendDto, StockDividendsDto, + StockDividendsResourceDto, StockPositionDto, + StockDividendDto, } from '../presentation/stock.dto'; - -import { StockCatalogService } from './stock-catalog.service'; - +import { decimal } from '../../../../../integrations/tbank/decimal'; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @Injectable() export class StockIncomeService { constructor( - private readonly catalog: StockCatalogService, - private readonly tbank: TbankClient, private readonly portfolios: PortfolioService, + private readonly sources: CatalogEnrichmentService, + private readonly access: TbankAccessService, + private readonly forecasts: ForecastService, ) {} - async position(userId: string, uid: string): Promise { - return this.catalog.cached(userId, `position:${uid}`, async () => - stockPosition(await this.portfolios.getAll(userId), uid), - ); + + async readPosition(userId: string, uid: string, observe = false) { + const saved = await this.portfolios.read(userId, 'all', observe); + return { + data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null, + meta: { ...saved.meta, resourceKey: `stock/position/${uid}` }, + }; } - dividends(userId: string, uid: string): Promise { - return this.catalog.cached(userId, `dividends:${uid}`, async (token) => { - await this.catalog.share(token, uid); - const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; - const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; - const unavailable: string[] = []; - const [events, position] = await Promise.all([ - this.tbank.getDividends(token, uid, from, to), - optional(() => this.position(userId, uid), 'position', unavailable), - ]); - const quantity = - position && - position.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; - const dividends: StockDividendDto[] = events - .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') - .map((event) => { - const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null; - const paymentDate = - timestamp(event.paymentDate)?.slice(0, 10) ?? null; - const lastBuyDate = - timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; - const currency = event.dividendNet?.currency?.toUpperCase(); - const perShare = - currency && event.dividendNet - ? { currency, value: formatDecimal(decimal(event.dividendNet)) } - : null; - // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. - const eventDate = paymentDate ?? recordDate; - const timing: StockDividendDto['timing'] = eventDate - ? eventDate >= today - ? 'upcoming' - : 'past' - : 'undated'; - const estimatedAmount = - quantity && - perShare && - recordDate && - recordDate >= today && - timing === 'upcoming' - ? { - currency: perShare.currency, - value: formatDecimal( - multiply(quantity, decimalFromString(perShare.value)), - ), - } - : null; - return { - perShare, - recordDate, - lastBuyDate, - paymentDate, - timing, - estimatedAmount, - }; - }) - .sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? '')); - return { - dividends, - from, - to, - observedAt: new Date().toISOString(), - positionAt: quantity ? (position?.observedAt ?? null) : null, - estimateUnavailable: unavailable.length > 0, - positionIsStale: position?.isStale ?? false, - }; + async dividends( + userId: string, + uid: string, + observe = false, + ): Promise { + const connection = await this.access.local(userId); + const today = moscowDay(new Date().toISOString()); + const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; + const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; + const portfolio = await this.portfolios.read(userId, 'all', observe); + const sources = await this.sources.readMany( + userId, + 'share', + [uid], + observe, + ['detail', 'dividends'], + { from, to }, + ); + if (sources[0].meta.availability === 'complete' && !sources[0].instrument) + throw new NotFoundException(); + const events = sources[1].payload?.dividends; + const position = portfolio.data + ? instrumentPosition(portfolio.data, uid, 'STOCK') + : null; + const projected = events + ? stockDividends( + events, + position, + { from, to, today }, + sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), + !portfolio.data, + ) + : undefined; + const result = await this.forecasts.result({ + userId, + revision: connection.revision, + membershipRevision: connection.membershipRevision, + scopeKey: `stock/dividends/${JSON.stringify([uid, from, to])}`, + portfolio: portfolio.meta, + sources, + candidate: + portfolio.data && sources[0].instrument ? projected : undefined, }); + return result.data || !projected + ? result + : { + data: stockDividends( + events ?? [], + null, + { from, to, today }, + projected.observedAt, + true, + ), + meta: { ...result.meta, availability: 'partial' }, + }; } } + +export function stockDividends( + events: TbankDividend[], + position: StockPositionDto | null, + window: { from: string; to: string; today: string }, + observedAt: string, + positionUnavailable: boolean, +): StockDividendsDto { + const { from, to, today } = window; + const quantity = + position && + position.accounts.length && + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) + ? decimalFromString(position.quantity) + : null; + const dividends: StockDividendDto[] = events + .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') + .map((event) => { + const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null; + const paymentDate = timestamp(event.paymentDate)?.slice(0, 10) ?? null; + const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; + const currency = event.dividendNet?.currency?.toUpperCase(); + const perShare = + currency && event.dividendNet + ? { currency, value: formatDecimal(decimal(event.dividendNet)) } + : null; + // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. + const eventDate = paymentDate ?? recordDate; + const timing: StockDividendDto['timing'] = eventDate + ? eventDate >= today + ? 'upcoming' + : 'past' + : 'undated'; + const estimatedAmount = + quantity && + perShare && + recordDate && + recordDate >= today && + timing === 'upcoming' + ? { + currency: perShare.currency, + value: formatDecimal( + multiply(quantity, decimalFromString(perShare.value)), + ), + } + : null; + return { + perShare, + recordDate, + lastBuyDate, + paymentDate, + timing, + estimatedAmount, + }; + }) + .sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? '')); + return { + dividends, + from, + to, + observedAt: observedAt, + positionAt: quantity ? (position?.observedAt ?? null) : null, + estimateUnavailable: positionUnavailable, + positionIsStale: position?.isStale ?? false, + }; +} diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index 3ea2464..efc6b7c 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -1,4 +1,9 @@ -import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { CandleService } from '../../application/candle.service'; +import { + CatalogEnrichmentService, + type CatalogPayload, +} from '../../application/catalog-enrichment.service'; +import { Injectable } from '@nestjs/common'; import { decimalFromString, divide, @@ -6,180 +11,101 @@ import { multiply, subtract, } from '../../../../../common/decimal'; -import { TbankClient } from '../../../../../integrations/tbank/tbank.api'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { timestamp } from '../../application/instrument-market'; import { - optional, - previousClose, - timestamp, -} from '../../application/instrument-market'; -import { - StockCandlesDto, StockCandlesQueryDto, StockQuoteDto, + StockQuoteResourceDto, } from '../presentation/stock.dto'; -const dayMs = 86_400_000; - -import { StockCatalogService } from './stock-catalog.service'; - -const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20; -const HALF_YEAR_MONTHS = 6; -const YEAR_MONTHS = 12; -const CANDLE_WINDOW_DAYS = 365; const CHANGE_PERCENT_DECIMAL_PLACES = 6; @Injectable() export class StockMarketService { constructor( - private readonly catalog: StockCatalogService, - private readonly tbank: TbankClient, + private readonly history: CandleService, + private readonly sources: CatalogEnrichmentService, ) {} - quote(userId: string, uid: string): Promise { - return this.catalog.cached(userId, `quote:${uid}`, async (token) => { - const share = await this.catalog.share(token, uid); - const unavailable: string[] = []; - const [last, tradingStatus] = await Promise.all([ - optional( - () => this.tbank.getLastPrice(token, uid), - 'price', - unavailable, - ), - optional( - () => this.tbank.getTradingStatus(token, uid), - 'status', - unavailable, - ), - ]); - const priceAt = timestamp(last?.time); - const currency = share.currency?.toUpperCase(); - const price = - last?.price && currency - ? { currency, value: formatDecimal(decimal(last.price)) } - : null; - const previous = priceAt - ? await optional( - async () => { - const end = new Date(priceAt); - return previousClose( - await this.tbank.getCandles( - token, - uid, - new Date( - end.valueOf() - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs, - ), - end, - ), - priceAt, - ); - }, - 'comparison', - unavailable, - ) - : null; - const previousPrice = - previous?.close && currency - ? { currency, value: formatDecimal(decimal(previous.close)) } - : null; - const difference = - price && previousPrice - ? subtract( - decimalFromString(price.value), - decimalFromString(previousPrice.value), - ) - : null; - return { - price, - priceAt, - tradingStatus, - previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, - previousClose: previousPrice, - change: - difference && currency - ? { currency, value: formatDecimal(difference) } - : null, - changePercent: - difference && - previousPrice && - decimalFromString(previousPrice.value).value > 0n - ? formatDecimal( - divide( - multiply(difference, decimalFromString('100')), - decimalFromString(previousPrice.value), - CHANGE_PERCENT_DECIMAL_PLACES, - ), - ) - : null, - unavailable, - observedAt: new Date().toISOString(), - }; - }); + async quote( + userId: string, + uid: string, + observe = false, + ): Promise { + const result = await this.sources.section( + userId, + 'share', + uid, + 'quote', + observe, + ); + return { + data: result.values.instrument + ? stockQuote(result.values, result.unavailable, result.observedAt) + : null, + meta: result.meta, + }; } candles( userId: string, uid: string, period: StockCandlesQueryDto['period'] = 'month', - ): Promise { - return this.catalog.cached( + observe = false, + ) { + return this.history.read( userId, - `candles:${uid}:${period}`, - async (token) => { - const share = await this.catalog.share(token, uid); - const historyFrom = timestamp(share.first_1dayCandleDate); - const to = new Date(); - const from = new Date(to); - from.setUTCMonth( - from.getUTCMonth() - - (period === 'month' - ? 1 - : period === 'halfYear' - ? HALF_YEAR_MONTHS - : YEAR_MONTHS), - ); - if (period === 'all') { - if (!historyFrom) - throw new ServiceUnavailableException({ - code: 'STOCK_HISTORY_START_UNAVAILABLE', - }); - from.setTime(Date.parse(historyFrom)); - } else if (historyFrom && Date.parse(historyFrom) > from.valueOf()) - from.setTime(Date.parse(historyFrom)); - const collected = new Map(); - // Conservative one-year windows also work with older deployed RPC limits. - for (let start = from.valueOf(); start < to.valueOf();) { - const end = Math.min( - start + CANDLE_WINDOW_DAYS * dayMs, - to.valueOf(), - ); - for (const candle of await this.tbank.getCandles( - token, - uid, - new Date(start), - new Date(end), - )) { - const time = timestamp(candle.time); - if ( - time && - candle.close && - Date.parse(time) >= from.valueOf() - dayMs && - Date.parse(time) <= to.valueOf() - ) - collected.set(time, { - time, - close: formatDecimal(decimal(candle.close)), - isComplete: candle.isComplete === true, - }); - } - start = end; - } - return { - candles: [...collected.values()].sort((a, b) => - a.time.localeCompare(b.time), - ), - currency: share.currency?.toUpperCase() || null, - historyFrom, - observedAt: new Date().toISOString(), - }; - }, + { uid, instrumentType: 'share', period }, + observe, ); } } + +export function stockQuote( + values: CatalogPayload, + unavailable: string[], + observedAt: string, +): StockQuoteDto { + const { price: last, candle: previous, tradingStatus = null } = values; + const share = values.instrument; + const priceAt = timestamp(last?.time); + const currency = share?.currency?.toUpperCase(); + const price = + last?.price && currency + ? { currency, value: formatDecimal(decimal(last.price)) } + : null; + const previousPrice = + previous?.close && currency + ? { currency, value: formatDecimal(decimal(previous.close)) } + : null; + const difference = + price && previousPrice + ? subtract( + decimalFromString(price.value), + decimalFromString(previousPrice.value), + ) + : null; + return { + price, + priceAt, + tradingStatus, + previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, + previousClose: previousPrice, + change: + difference && currency + ? { currency, value: formatDecimal(difference) } + : null, + changePercent: + difference && + previousPrice && + decimalFromString(previousPrice.value).value > 0n + ? formatDecimal( + divide( + multiply(difference, decimalFromString('100')), + decimalFromString(previousPrice.value), + CHANGE_PERCENT_DECIMAL_PLACES, + ), + ) + : null, + unavailable, + observedAt, + }; +} diff --git a/src/modules/investments/instruments/stocks/application/stock.spec.ts b/src/modules/investments/instruments/stocks/application/stock.spec.ts index 9b3035a..2ae894b 100644 --- a/src/modules/investments/instruments/stocks/application/stock.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock.spec.ts @@ -1,375 +1,70 @@ -import { - ConflictException, - NotFoundException, - ServiceUnavailableException, - UnauthorizedException, -} from '@nestjs/common'; -import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service'; -import { TbankClient } from '../../../../../integrations/tbank/tbank.client'; -import { PortfolioService } from '../../../portfolio/application/portfolio.service'; -import { GetStockCompanyUseCase } from './get-stock-company.use-case'; -import { StockIncomeService } from './stock-income.service'; -import { StockMarketService } from './stock-market.service'; -import { StockCatalogService } from './stock-catalog.service'; +import { metrics } from './get-stock-company.use-case'; +import { stockDividends } from './stock-income.service'; +import { stockQuote } from './stock-market.service'; import { previousClose } from '../../application/instrument-market'; -jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({ - TbankAccessService: class TbankAccessService {}, +jest.mock('../../../../../prisma/prisma.service', () => ({ + PrismaService: class {}, })); -const time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) }); -const share = { - uid: 'uid', - assetUid: 'asset', - currency: 'rub', - sector: 'financial', - first_1dayCandleDate: time('2025-11-20'), -}; -function setup() { - let generation = 'a'; - const tbank = { - findShares: jest.fn().mockResolvedValue([]), - getLastPrices: jest.fn().mockResolvedValue([]), - getShare: jest.fn().mockResolvedValue(share), - getCandles: jest.fn().mockResolvedValue([]), - getAssetDescription: jest.fn().mockResolvedValue('Company'), - getFundamentals: jest.fn().mockResolvedValue({ - assetUid: 'asset', - currency: 'rub', - peRatioTtm: 6, - netIncomeTtm: 10, - }), - getDividends: jest.fn().mockResolvedValue([]), - }; - const access = { - withConnection: jest.fn( - (user: string, handler: (context: unknown) => unknown) => - handler({ token: user, generation }), - ), - }; - const portfolios = { - getAll: jest.fn().mockResolvedValue({ - positions: [], - lastSuccessfulAt: '2026-09-10T00:00:00Z', - isStale: false, - }), - }; - const service = new StockCatalogService( - access as unknown as TbankAccessService, - tbank as unknown as TbankClient, - ); - const market = new StockMarketService( - service, - tbank as unknown as TbankClient, - ); - const income = new StockIncomeService( - service, - tbank as unknown as TbankClient, - portfolios as unknown as PortfolioService, - ); - const companyInfo = new GetStockCompanyUseCase( - service, - tbank as unknown as TbankClient, - ); - return { - market, - income, - companyInfo, - service, - tbank, - portfolios, - changeGeneration: () => { - generation = 'b'; - }, - }; -} -describe('stock market use case', () => { - beforeEach(() => - jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')), - ); - afterEach(() => jest.useRealTimers()); - it('returns unique ISINs, keeps preferred shares separate and matches prices by selected UID', async () => { - const { service, tbank } = setup(); - const listings = [ - { - uid: 'special', - ticker: 'SBER', - classCode: 'SPEQ', - apiTradeAvailableFlag: false, - }, - { - uid: 'preferred', - ticker: 'SBERP', - classCode: 'TQBR', - apiTradeAvailableFlag: true, - }, - { - uid: 'common', - ticker: 'SBER', - classCode: 'TQBR', - apiTradeAvailableFlag: true, - }, - ].map((item) => ({ - ...item, - name: 'Сбербанк', - isin: item.uid === 'preferred' ? 'ISINP' : 'ISIN', - instrumentKind: 'INSTRUMENT_TYPE_SHARE', - })); - tbank.findShares.mockResolvedValue([ - ...listings, - { uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' }, - { instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, - { uid: '', instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, - ]); - tbank.getShare.mockImplementation((_token: string, uid: string) => - Promise.resolve({ - ...listings.find((item) => item.uid === uid), - currency: 'rub', - brand: { logoName: 'sber.png' }, - shareType: - uid === 'preferred' ? 'SHARE_TYPE_PREFERRED' : 'SHARE_TYPE_COMMON', - }), +const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) }); +describe('stock financial projections', () => { + it('keeps raw public payouts when holdings are unavailable and omits cancelled events', () => { + const result = stockDividends( + [ + { + dividendNet: { currency: 'rub', units: '32', nano: 880000000 }, + recordDate: time('2026-10-13'), + paymentDate: time('2026-10-27'), + }, + { dividendType: 'Cancelled', recordDate: time('2026-10-14') }, + ], + null, + { from: '2021-09-10', to: '2027-09-10', today: '2026-09-10' }, + 'source-time', + true, ); - tbank.getLastPrices.mockResolvedValue([ + expect(result.dividends).toEqual([ { - instrumentUid: 'preferred', - price: { units: '305' }, - time: time('2026-09-09'), - }, - { - instrumentUid: 'common', - price: { units: '306', nano: 120000000 }, - time: time('2026-09-10'), + perShare: { currency: 'RUB', value: '32.88' }, + recordDate: '2026-10-13', + paymentDate: '2026-10-27', + lastBuyDate: null, + timing: 'upcoming', + estimatedAmount: null, }, ]); - const result = await service.search('user', 'SBER'); - expect(result.map((item) => item.instrumentUid)).toEqual([ - 'common', - 'preferred', - ]); - expect(result[0]).toMatchObject({ - classCode: 'TQBR', - isin: 'ISIN', - logoName: 'sber.png', - price: { currency: 'RUB', value: '306.12' }, - priceAt: '2026-09-10T00:00:00.000Z', - }); - expect(result[1]).toMatchObject({ - shareType: 'SHARE_TYPE_PREFERRED', - price: { value: '305' }, - }); - expect(tbank.getLastPrices).toHaveBeenCalledTimes(1); + expect(result.estimateUnavailable).toBe(true); + expect(result.observedAt).toBe('source-time'); }); - it('prefers TQBR among equally available listings, deduplicates before the limit and keeps missing ISINs separate', async () => { - const { service, tbank } = setup(); - const hit = { - instrumentKind: 'INSTRUMENT_TYPE_SHARE', - ticker: 'SBER', - apiTradeAvailableFlag: true, - }; - tbank.findShares.mockResolvedValue([ - ...Array.from({ length: 35 }, (_, i) => ({ - ...hit, - uid: `alternate-${i}`, - isin: ' isin ', - classCode: 'SPEQ', - })), - { ...hit, uid: 'common', isin: 'ISIN', classCode: 'TQBR' }, - { - ...hit, - uid: 'preferred', - ticker: 'SBERP', - isin: 'ISINP', - classCode: 'TQBR', - }, - { ...hit, uid: 'unknown-a', isin: '' }, - { ...hit, uid: 'unknown-b' }, - ]); - tbank.getShare.mockResolvedValue(null); - const result = await service.search('user', 'SBER'); - expect(result.map((item) => item.instrumentUid)).toEqual([ - 'common', - 'unknown-a', - 'unknown-b', - 'preferred', - ]); - expect(tbank.getShare).toHaveBeenCalledTimes(4); - }); - it('retains catalog identity when enrichment is unavailable, but propagates auth errors', async () => { - const { service, tbank } = setup(); - tbank.findShares.mockResolvedValue([ - { - uid: 'uid', - ticker: 'SBER', - classCode: 'TQBR', - instrumentKind: 'INSTRUMENT_TYPE_SHARE', - }, - ]); - tbank.getShare.mockRejectedValue(new ServiceUnavailableException()); - tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException()); - expect(await service.search('user', 'SBER')).toEqual([ - expect.objectContaining({ - instrumentUid: 'uid', - classCode: 'TQBR', - price: null, - logoName: null, - }), - ]); - tbank.getShare.mockResolvedValue(null); - expect(await service.search('user', 'sber')).toEqual([ - expect.objectContaining({ instrumentUid: 'uid' }), - ]); - tbank.getShare.mockRejectedValue(new UnauthorizedException()); - await expect(service.search('another-user', 'SBER')).rejects.toBeInstanceOf( - UnauthorizedException, - ); - }); - it('skips enrichment for an empty catalog result', async () => { - const { service, tbank } = setup(); - expect(await service.search('user', 'none')).toEqual([]); - expect(tbank.getShare).not.toHaveBeenCalled(); - expect(tbank.getLastPrices).not.toHaveBeenCalled(); - }); - it('resolves an exact ticker, preserves other listing UIDs and does not confuse SBER with SBERP', async () => { - const { service, tbank } = setup(); - const listings = [ - { - uid: 'common', - ticker: 'SBER', - isin: 'COMMON', - classCode: 'TQBR', - apiTradeAvailableFlag: true, - }, - { - uid: 'special', - ticker: 'SBER', - isin: 'COMMON', - classCode: 'SPEQ', - apiTradeAvailableFlag: false, - }, - { - uid: 'preferred', - ticker: 'SBERP', - isin: 'PREFERRED', - classCode: 'TQBR', - apiTradeAvailableFlag: true, - }, - ].map((item) => ({ ...item, instrumentKind: 'INSTRUMENT_TYPE_SHARE' })); - tbank.findShares.mockResolvedValue(listings); - tbank.getShare.mockImplementation((_token: string, uid: string) => - Promise.resolve(listings.find((item) => item.uid === uid)), - ); - expect(await service.getByTicker('user', 'sberp')).toMatchObject({ - instrumentUid: 'preferred', - slug: 'SBERP', - }); - expect(await service.get('user', 'common')).toMatchObject({ - instrumentUid: 'common', - slug: 'SBER', - }); - expect(await service.get('user', 'special')).toMatchObject({ - instrumentUid: 'special', - slug: 'special', - }); - await expect(service.getByTicker('user', 'SB')).rejects.toBeInstanceOf( - NotFoundException, - ); - }); - it('refuses ambiguous tickers while keeping exact UID pages accessible', async () => { - const { service, tbank } = setup(); - const listings = ['one', 'two'].map((uid) => ({ - uid, - ticker: 'SAME', - isin: uid, - instrumentKind: 'INSTRUMENT_TYPE_SHARE', - })); - tbank.findShares.mockResolvedValue(listings); - tbank.getShare.mockResolvedValue(listings[0]); - await expect(service.getByTicker('user', 'SAME')).rejects.toBeInstanceOf( - ConflictException, - ); - expect(await service.get('user', 'one')).toMatchObject({ - instrumentUid: 'one', - slug: 'one', - }); - }); - it('keeps exact pages if alias lookup fails and partitions ticker resolution by connection', async () => { - const { service, tbank, changeGeneration } = setup(); - tbank.getShare.mockResolvedValue({ ...share, ticker: 'F' }); - tbank.findShares.mockRejectedValueOnce(new ServiceUnavailableException()); - expect(await service.get('user', 'uid')).toMatchObject({ slug: 'uid' }); - tbank.findShares.mockResolvedValue([ - { ...share, ticker: 'F', instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, - ]); - expect(await service.getByTicker('user', 'F')).toMatchObject({ - instrumentUid: 'uid', - slug: 'F', - }); - changeGeneration(); - expect(await service.getByTicker('user', 'F')).toMatchObject({ slug: 'F' }); - expect(tbank.findShares).toHaveBeenCalledTimes(3); - }); - it('uses a complete previous trading day, never today’s main close', () => { + it('uses the last complete previous trading day', () => { const candles = [ { time: time('2026-09-09'), close: { units: '10' }, isComplete: true }, - { time: time('2026-09-10'), close: { units: '11' }, isComplete: true }, - { time: time('2026-09-08'), close: { units: '9' }, isComplete: false }, + { time: time('2026-09-10'), close: { units: '11' }, isComplete: false }, ]; expect(previousClose(candles, '2026-09-10T18:00:00Z')).toEqual(candles[0]); - expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]); }); - it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => { - const { tbank, market } = setup(); - tbank.getCandles.mockResolvedValue([ - { time: time('2026-09-09'), close: { units: '10' }, isComplete: false }, - ]); - const result = await market.candles('user', 'uid', 'all'); - expect(tbank.getCandles).toHaveBeenCalledWith( - 'user', - 'uid', - new Date('2025-11-20'), - expect.any(Date), - ); - expect(result.candles).toHaveLength(1); - expect(result.candles[0].isComplete).toBe(false); - }); - it('does not manufacture zero fundamentals and isolates optional failures', async () => { - const { tbank, companyInfo } = setup(); - tbank.getAssetDescription.mockRejectedValue( - new ServiceUnavailableException(), - ); - const result = await companyInfo.execute('user', 'uid'); - expect(result.description).toBeNull(); - expect(result.unavailable).toEqual(['description']); - expect(result.metrics.map((item) => item.key)).toEqual([ - 'peRatioTtm', - 'netIncomeTtm', - ]); - }); - it('isolates cache by user and connection generation', async () => { - const { service, tbank, changeGeneration } = setup(); - await service.get('a', 'uid'); - await service.get('a', 'uid'); - expect(tbank.getShare).toHaveBeenCalledTimes(1); - await service.get('b', 'uid'); - changeGeneration(); - await service.get('a', 'uid'); - expect(tbank.getShare).toHaveBeenCalledTimes(3); - }); - it('keeps public dividends when there is no holding or position retrieval fails', async () => { - const { tbank, portfolios, income } = setup(); - portfolios.getAll.mockRejectedValue(new ServiceUnavailableException()); - tbank.getDividends.mockResolvedValue([ - { - dividendNet: { currency: 'rub', units: '32', nano: 880000000 }, - recordDate: time('2026-10-13'), - paymentDate: time('2026-10-27'), - }, - ]); - const result = await income.dividends('user', 'uid'); - expect(result.estimateUnavailable).toBe(true); - expect(result.dividends[0]).toMatchObject({ - perShare: { currency: 'RUB', value: '32.88' }, - estimatedAmount: null, - timing: 'upcoming', + it('retains stock price precision and percent change without inventing unavailable fundamentals', () => { + expect( + stockQuote( + { + instrument: { currency: 'rub' }, + price: { price: { units: '11', nano: 1 } }, + candle: { close: { units: '10' } }, + }, + [], + 'time', + ), + ).toMatchObject({ + price: { currency: 'RUB', value: '11.000000001' }, + change: { currency: 'RUB', value: '1.000000001' }, + changePercent: '10', }); + expect(metrics(null, 'financial')).toEqual([]); + expect( + metrics( + { peRatioTtm: 6, netIncomeTtm: 10, revenueTtm: 100, currency: 'rub' }, + 'financial', + ).map((item) => item.key), + ).toEqual(['peRatioTtm', 'netIncomeTtm']); + expect(metrics({ netIncomeTtm: 10 })).toEqual([]); }); }); diff --git a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts index 766249d..aa8dfe7 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts @@ -1,3 +1,4 @@ +import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; import { Controller, Get, @@ -15,15 +16,15 @@ import { GetStockCompanyUseCase } from '../application/get-stock-company.use-cas import { StockIncomeService } from '../application/stock-income.service'; import { StockMarketService } from '../application/stock-market.service'; import { - StockCandlesDto, + StockCandlesResourceDto, StockCandlesQueryDto, - StockCompanyDto, - StockDividendsDto, - StockDto, - StockPositionDto, - StockQuoteDto, + StockCompanyResourceDto, + StockDividendsResourceDto, + StockResourceDto, + StockPositionResourceDto, + StockQuoteResourceDto, StockSearchQueryDto, - StockSearchResultDto, + StockSearchResourceDto, StockTickerParamsDto, } from './stock.dto'; import { StockCatalogService } from '../application/stock-catalog.service'; @@ -41,68 +42,99 @@ export class StockController { private readonly companyInfo: GetStockCompanyUseCase, ) {} @Get() - @ApiOkResponse({ type: [StockSearchResultDto] }) + @ApiOkResponse({ type: StockSearchResourceDto }) search( @Req() request: Request & { user: AccessPayload }, @Query() query: StockSearchQueryDto, ) { - return this.stocks.search(request.user.sub, query.query); + return this.stocks.search( + request.user.sub, + query.query, + query.sync === 'observe', + ); } @Get('by-ticker/:ticker') - @ApiOkResponse({ type: StockDto }) + @ApiOkResponse({ type: StockResourceDto }) getByTicker( @Req() request: Request & { user: AccessPayload }, @Param() params: StockTickerParamsDto, + @Query() query: InvestmentReadQueryDto, ) { - return this.stocks.getByTicker(request.user.sub, params.ticker); + return this.stocks.getByTicker( + request.user.sub, + params.ticker, + query.sync === 'observe', + ); } @Get(':uid') - @ApiOkResponse({ type: StockDto }) + @ApiOkResponse({ type: StockResourceDto }) get( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.stocks.get(request.user.sub, uid); + return this.stocks.get(request.user.sub, uid, query.sync === 'observe'); } @Get(':uid/quote') - @ApiOkResponse({ type: StockQuoteDto }) + @ApiOkResponse({ type: StockQuoteResourceDto }) quote( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.market.quote(request.user.sub, uid); + return this.market.quote(request.user.sub, uid, query.sync === 'observe'); } @Get(':uid/candles') - @ApiOkResponse({ type: StockCandlesDto }) + @ApiOkResponse({ type: StockCandlesResourceDto }) candles( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, @Query() query: StockCandlesQueryDto, ) { - return this.market.candles(request.user.sub, uid, query.period); + return this.market.candles( + request.user.sub, + uid, + query.period, + query.sync === 'observe', + ); } @Get(':uid/position') - @ApiOkResponse({ type: StockPositionDto }) + @ApiOkResponse({ type: StockPositionResourceDto }) position( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.income.position(request.user.sub, uid); + return this.income.readPosition( + request.user.sub, + uid, + query.sync === 'observe', + ); } @Get(':uid/company') - @ApiOkResponse({ type: StockCompanyDto }) + @ApiOkResponse({ type: StockCompanyResourceDto }) company( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.companyInfo.execute(request.user.sub, uid); + return this.companyInfo.execute( + request.user.sub, + uid, + query.sync === 'observe', + ); } @Get(':uid/dividends') - @ApiOkResponse({ type: StockDividendsDto }) + @ApiOkResponse({ type: StockDividendsResourceDto }) dividends( @Req() request: Request & { user: AccessPayload }, @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.income.dividends(request.user.sub, uid); + return this.income.dividends( + request.user.sub, + uid, + query.sync === 'observe', + ); } } diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index 405e255..c722395 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -1,3 +1,7 @@ +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { @@ -13,7 +17,7 @@ import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; const MAX_INSTRUMENT_QUERY_LENGTH = 80; -export class StockSearchQueryDto { +export class StockSearchQueryDto extends InvestmentReadQueryDto { @ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @Transform(({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value, @@ -35,7 +39,7 @@ export class StockTickerParamsDto { @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) declare ticker: string; } -export class StockCandlesQueryDto { +export class StockCandlesQueryDto extends InvestmentReadQueryDto { @ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'], default: 'month', @@ -168,3 +172,52 @@ export class StockDividendsDto { @ApiProperty() declare estimateUnavailable: boolean; @ApiProperty() declare positionIsStale: boolean; } + +export class StockPositionResourceDto { + @ApiProperty({ type: StockPositionDto, nullable: true }) + declare data: StockPositionDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class StockSearchResourceDto { + @ApiProperty({ type: StockSearchResultDto, isArray: true, nullable: true }) + declare data: StockSearchResultDto[] | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class StockResourceDto { + @ApiProperty({ type: StockDto, nullable: true }) + declare data: StockDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class StockQuoteResourceDto { + @ApiProperty({ type: StockQuoteDto, nullable: true }) + declare data: StockQuoteDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class StockCompanyResourceDto { + @ApiProperty({ type: StockCompanyDto, nullable: true }) + declare data: StockCompanyDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class StockCandlesResourceDto { + @ApiProperty({ type: StockCandlesDto, nullable: true }) + declare data: StockCandlesDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} + +export class StockDividendsResourceDto { + @ApiProperty({ type: StockDividendsDto, nullable: true }) + declare data: StockDividendsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} diff --git a/src/modules/investments/investment-calendar.ts b/src/modules/investments/investment-calendar.ts index 051bfbd..abb6902 100644 --- a/src/modules/investments/investment-calendar.ts +++ b/src/modules/investments/investment-calendar.ts @@ -7,13 +7,14 @@ const MOSCOW_DAY_END_UTC = { }; export type DateOnlyRange = { from: string; to: string }; const moscow = 'Europe/Moscow'; +const moscowFormatter = new Intl.DateTimeFormat('en-CA', { + timeZone: moscow, + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); export function moscowDate(value: Date): string { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: moscow, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(value); + const parts = moscowFormatter.formatToParts(value); const get = (type: string) => parts.find((part) => part.type === type)?.value; return `${get('year')}-${get('month')}-${get('day')}`; } diff --git a/src/modules/investments/investment-resource.dto.ts b/src/modules/investments/investment-resource.dto.ts new file mode 100644 index 0000000..bf0496a --- /dev/null +++ b/src/modules/investments/investment-resource.dto.ts @@ -0,0 +1,75 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +export const availabilityValues = ['missing', 'partial', 'complete'] as const; +export const freshnessValues = ['unknown', 'fresh', 'stale'] as const; +export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const; + +export class InvestmentReadQueryDto { + @ApiPropertyOptional({ enum: ['observe'] }) + @IsOptional() + @IsIn(['observe']) + declare sync?: 'observe'; +} + +export class InvestmentSyncDto { + @ApiProperty({ type: String, nullable: true }) + declare id: string | null; + @ApiProperty({ enum: syncStateValues }) + declare state: (typeof syncStateValues)[number]; + @ApiProperty({ type: String, nullable: true }) + declare errorCode: string | null; +} + +export class InvestmentRangeDto { + @ApiPropertyOptional() + accountId?: string; + @ApiProperty({ format: 'date-time' }) + declare from: string; + @ApiProperty({ format: 'date-time' }) + declare to: string; +} + +export class InvestmentCoverageDto { + @ApiProperty({ type: InvestmentRangeDto, isArray: true }) + declare requested: InvestmentRangeDto[]; + @ApiProperty({ type: InvestmentRangeDto, isArray: true }) + declare completeRanges: InvestmentRangeDto[]; + @ApiProperty({ type: InvestmentRangeDto, isArray: true }) + declare missingRanges: InvestmentRangeDto[]; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + declare asOf: string | null; + @ApiProperty({ enum: ['known', 'unknown'] }) + declare historyBoundary: 'known' | 'unknown'; + @ApiProperty({ + enum: ['missing', 'loading', 'partial', 'complete', 'failed'], + }) + declare historyState: + 'missing' | 'loading' | 'partial' | 'complete' | 'failed'; +} + +export class InvestmentDependencyDto { + @ApiProperty() + declare dataRevision: string; + @ApiProperty() + declare stateVersion: string; + @ApiPropertyOptional({ type: String }) + unavailableReason?: string; + @ApiPropertyOptional({ type: () => InvestmentCoverageDto }) + coverage?: InvestmentCoverageDto; + @ApiProperty() + declare resourceKey: string; + @ApiProperty({ enum: availabilityValues }) + declare availability: (typeof availabilityValues)[number]; + @ApiProperty({ enum: freshnessValues }) + declare freshness: (typeof freshnessValues)[number]; + @ApiProperty({ type: String, format: 'date-time', nullable: true }) + declare lastSuccessfulAt: string | null; + @ApiProperty({ type: InvestmentSyncDto }) + declare sync: InvestmentSyncDto; +} + +export class InvestmentResourceMetaDto extends InvestmentDependencyDto { + @ApiPropertyOptional({ type: InvestmentDependencyDto, isArray: true }) + declare dependencies?: InvestmentDependencyDto[]; +} diff --git a/src/modules/investments/investment-resource.mapper.spec.ts b/src/modules/investments/investment-resource.mapper.spec.ts new file mode 100644 index 0000000..2fb772e --- /dev/null +++ b/src/modules/investments/investment-resource.mapper.spec.ts @@ -0,0 +1,80 @@ +import { investmentResourceMeta } from './investment-resource.mapper'; + +describe('investment resource source freshness', () => { + afterEach(() => jest.useRealTimers()); + + it('uses each required source TTL and oldest successful timestamp', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-25T12:00:00Z')); + const membership = investmentResourceMeta({ + resourceKey: 'membership', + dataRevision: '1', + availability: 'complete', + fetchedAt: new Date('2026-09-25T11:58:00Z'), + freshForMs: 300_000, + work: null, + }); + const input = { + resourceKey: 'portfolio', + dataRevision: '1', + availability: 'complete' as const, + fetchedAt: new Date('2026-09-25T11:59:30Z'), + freshForMs: 60_000, + work: null, + dependencies: [membership], + }; + const fresh = investmentResourceMeta(input); + expect(fresh.freshness).toBe('fresh'); + expect(fresh.lastSuccessfulAt).toBe('2026-09-25T11:58:00.000Z'); + jest.advanceTimersByTime(30_000); + const stale = investmentResourceMeta(input); + expect(stale.freshness).toBe('stale'); + expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt); + expect(stale.stateVersion).not.toBe(fresh.stateVersion); + expect( + investmentResourceMeta({ + ...input, + dependencies: [{ ...membership, freshness: 'unknown' }], + }).freshness, + ).toBe('unknown'); + }); +}); + +describe('dependent resource lifecycle', () => { + const input = { + resourceKey: 'source', + dataRevision: '1', + availability: 'complete' as const, + fetchedAt: new Date(), + freshForMs: 60_000, + work: null, + }; + it('exposes dependency work without losing saved availability or timestamps', () => { + const failed = investmentResourceMeta({ + ...input, + work: { id: 'failed', state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, + }); + const running = investmentResourceMeta({ + ...input, + work: { id: 'running', state: 'running', errorCode: null }, + }); + const parent = investmentResourceMeta({ + ...input, + dependencies: [failed, running], + }); + expect(parent.sync).toEqual(running.sync); + expect(parent.availability).toBe('complete'); + const terminal = investmentResourceMeta({ + ...input, + dependencies: [failed], + }); + expect(terminal.sync).toEqual(failed.sync); + expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt); + expect(terminal.stateVersion).not.toBe(parent.stateVersion); + expect( + investmentResourceMeta({ + ...input, + dependencies: [{ ...failed, unavailableReason: 'calendar_boundary' }], + }).sync.state, + ).toBe('idle'); + }); +}); diff --git a/src/modules/investments/investment-resource.mapper.ts b/src/modules/investments/investment-resource.mapper.ts new file mode 100644 index 0000000..0e47aea --- /dev/null +++ b/src/modules/investments/investment-resource.mapper.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto'; +import { InvestmentResourceMetaDto } from './investment-resource.dto'; + +export function investmentResourceMeta(input: { + resourceKey: string; + dataRevision: string; + availability: InvestmentResourceMetaDto['availability']; + fetchedAt: Date | null; + freshForMs: number; + work: { id: string; state: string; errorCode: string | null } | null; + dependencies?: InvestmentResourceMetaDto[]; + coverage?: InvestmentResourceMetaDto['coverage']; + unavailableReason?: string; +}): InvestmentResourceMetaDto { + const ownSync = { + id: input.work?.id ?? null, + state: + input.work?.state === 'running' + ? ('running' as const) + : input.work?.state === 'failed' + ? ('failed' as const) + : ('idle' as const), + errorCode: input.work?.errorCode ?? null, + }; + const states = [ + ownSync, + ...(input.dependencies ?? []) + .filter((dependency) => !dependency.unavailableReason) + .map((dependency) => dependency.sync), + ]; + const sync = + states.find((state) => state.state === 'running') ?? + states.find((state) => state.state === 'queued') ?? + states.find((state) => state.state === 'failed') ?? + ownSync; + const sourceFreshness = input.fetchedAt + ? Date.now() - input.fetchedAt.valueOf() < input.freshForMs + ? ('fresh' as const) + : ('stale' as const) + : ('unknown' as const); + const freshness = [ + sourceFreshness, + ...(input.dependencies ?? []) + .filter((dependency) => !dependency.unavailableReason) + .map((dependency) => dependency.freshness), + ]; + const timestamps = [ + input.fetchedAt?.toISOString(), + ...(input.dependencies ?? []).map( + (dependency) => dependency.lastSuccessfulAt, + ), + ] + .filter((timestamp): timestamp is string => Boolean(timestamp)) + .sort(); + const meta = { + resourceKey: input.resourceKey, + ...(input.unavailableReason + ? { unavailableReason: input.unavailableReason } + : {}), + dataRevision: input.dataRevision, + availability: input.availability, + freshness: freshness.includes('unknown') + ? ('unknown' as const) + : freshness.includes('stale') + ? ('stale' as const) + : ('fresh' as const), + lastSuccessfulAt: timestamps[0] ?? null, + sync, + ...(input.coverage ? { coverage: input.coverage } : {}), + ...(input.dependencies ? { dependencies: input.dependencies } : {}), + }; + return { + ...meta, + stateVersion: createHash('sha256') + .update( + JSON.stringify({ + ...meta, + // The requested window is identified by resourceKey. Its moving "now" + // boundary is not a change to saved coverage between status and GET. + coverage: meta.coverage + ? { ...meta.coverage, requested: undefined } + : undefined, + }), + ) + .digest('base64url'), + }; +} diff --git a/src/modules/investments/investment-sync-plan.spec.ts b/src/modules/investments/investment-sync-plan.spec.ts new file mode 100644 index 0000000..82b2b00 --- /dev/null +++ b/src/modules/investments/investment-sync-plan.spec.ts @@ -0,0 +1,189 @@ +jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class {} })); + +import { + planInvestmentScreen, + ledgerRange, + accountHistory, +} from './investment-sync-plan'; + +describe('screen dependency and history planning', () => { + const now = new Date('2026-09-25T12:00:00Z'); + const context = { + membershipKnown: true, + accounts: [{ accountId: 'a', openedOn: '2020-01-01' }], + instruments: [ + { + uid: 'uid', + type: 'STOCK' as const, + historyFrom: new Date('2020-01-01Z'), + }, + ], + }; + it('prioritizes discovery, selected/comparisons, then history; Moscow dates are converted once', () => { + const plan = planInvestmentScreen( + { + screen: 'income', + accountId: 'a', + from: '2024-02-01', + to: '2024-02-29', + }, + context, + now, + ); + expect(plan.sources[0].kind).toBe('membership'); + const selected = plan.sources.filter( + (source) => source.kind === 'operations' && source.priority === 'screen', + ); + expect( + selected.some( + (source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z', + ), + ).toBe(true); + expect( + selected.some( + (source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z', + ), + ).toBe(true); + expect(plan.unavailable).toEqual([ + { resourceKey: 'income/yearAgo', reason: 'calendar_or_account_boundary' }, // gitleaks:allow public resource identifier + ]); + const firstHistory = plan.sources.findIndex( + (source) => source.priority === 'history', + ); + expect( + plan.sources + .slice(firstHistory) + .every((source) => source.priority === 'history'), + ).toBe(true); + }); + it('force contains only shown dependencies and retains instrument reconciliation predicates', () => { + const plan = planInvestmentScreen( + { + screen: 'stock', + instrumentUid: 'uid', + from: '2026-01-01', + to: '2026-02-01', + candlePeriod: 'year', + }, + context, + now, + true, + ); + expect(plan.sources.map((source) => source.kind)).toEqual( + expect.arrayContaining([ + 'membership', + 'portfolio', + 'operations', + 'metadata', + 'quote', + 'company', + 'calendar', + 'candles', + ]), + ); + expect(plan.sources.every((source) => source.priority !== 'history')).toBe( + true, + ); + expect( + plan.sources + .filter((source) => source.kind === 'operations') + .every((source) => source.instrumentUid === 'uid'), + ).toBe(true); + const accounts = planInvestmentScreen( + { screen: 'accounts' }, + context, + now, + true, + ); + expect( + accounts.sources.some((source) => source.kind === 'operations'), + ).toBe(false); + }); + it('never invents an account or candle history boundary; discovery gates unknown membership', () => { + const unknown = { + ...context, + accounts: [{ accountId: 'a', openedOn: null }], + instruments: [], + }; + expect(accountHistory(unknown.accounts, now)[0].from.toISOString()).toBe( + '1970-01-01T00:00:00.000Z', + ); + expect( + ledgerRange( + undefined, + undefined, + 'uid', + unknown.accounts, + now, + ).from.toISOString(), + ).toBe('1970-01-01T00:00:00.000Z'); + const plan = planInvestmentScreen( + { screen: 'bond', instrumentUid: 'uid', candlePeriod: 'all' }, + unknown, + now, + ); + expect(plan.sources.some((source) => source.kind === 'candles')).toBe( + false, + ); + expect(plan.unavailable[0].reason).toBe('history_start_unavailable'); + expect( + planInvestmentScreen( + { screen: 'account' }, + { ...context, membershipKnown: false }, + now, + ).sources, + ).toEqual([{ kind: 'membership', priority: 'discovery' }]); + }); + it('keeps UTC event days separate from Moscow instrument calendar days at year rollover', () => { + const rollover = new Date('2026-12-31T22:00:00Z'); + const events = planInvestmentScreen( + { screen: 'events' }, + context, + rollover, + true, + ); + expect( + events.sources.find((source) => source.kind === 'calendar')?.from, + ).toEqual(new Date('2026-12-31Z')); + const stock = planInvestmentScreen( + { screen: 'stock', instrumentUid: 'uid' }, + context, + rollover, + true, + ); + expect( + stock.sources.find((source) => source.kind === 'calendar'), + ).toMatchObject({ + from: new Date('2022-01-01Z'), + to: new Date('2028-01-01Z'), + }); + expect(rollover.toISOString()).toBe('2026-12-31T22:00:00.000Z'); + }); + + it('keeps search queries separate and events scoped to their inclusive period', () => { + const search = planInvestmentScreen( + { screen: 'stocks', query: ' sber ' }, + context, + now, + true, + ); + expect( + search.sources.find((source) => source.kind === 'catalog')?.query, + ).toBe('SBER'); + const events = planInvestmentScreen( + { screen: 'events', from: '2026-10-01', to: '2026-10-31' }, + context, + now, + true, + ); + expect( + events.sources.find((source) => source.kind === 'calendar'), + ).toMatchObject({ + from: new Date('2026-10-01Z'), + to: new Date('2026-10-31Z'), + }); + expect(events.sources.some((source) => source.kind === 'operations')).toBe( + false, + ); + }); +}); diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts new file mode 100644 index 0000000..638c176 --- /dev/null +++ b/src/modules/investments/investment-sync-plan.ts @@ -0,0 +1,330 @@ +import { planIncomePeriod } from './income/income.api'; +import { dateOnlyBounds, moscowDate } from './investment-calendar'; +import { + operationIntervals, + type OperationInterval, +} from './operations/operations.api'; + +export type InvestmentScreen = { + screen: + | 'accounts' + | 'account' + | 'operations' + | 'income' + | 'events' + | 'stocks' + | 'bonds' + | 'stock' + | 'bond'; + accountId?: string; + from?: string; + to?: string; + instrumentUid?: string; + query?: string; + candlePeriod?: 'month' | 'halfYear' | 'year' | 'all'; + candleRange?: { from: Date; to: Date }; +}; +const defaultEventDays = 30; +const incomeHistoryYears = 5; +const candleMonths = { month: 1, halfYear: 6, year: 12 }; + +type Account = { accountId: string; openedOn: string | null }; +type Instrument = { + uid: string; + type: 'STOCK' | 'BOND'; + historyFrom: Date | null; +}; +type Source = { + kind: + | 'membership' + | 'portfolio' + | 'operations' + | 'metadata' + | 'catalog' + | 'quote' + | 'company' + | 'metrics' + | 'calendar' + | 'candles'; + accountId?: string; + instrumentUid?: string; + instrumentType?: 'STOCK' | 'BOND'; + from?: Date; + to?: Date; + query?: string; + priority: 'discovery' | 'screen' | 'history'; +}; + +// Expands screen scope, never credentials or arbitrary caller-supplied jobs. +// Owners execute their own sources and expand instruments after discovery. +export function planInvestmentScreen( + screen: InvestmentScreen, + context: { + accounts: Account[]; + instruments: Instrument[]; + membershipKnown: boolean; + }, + now: Date, + force = false, +) { + const sources: Source[] = [{ kind: 'membership', priority: 'discovery' }]; + const accounts = context.accounts.filter( + (account) => + !screen.accountId || + screen.accountId === 'all' || + account.accountId === screen.accountId, + ); + const unavailable: { resourceKey: string; reason: string }[] = []; + const operations = (from: Date, to: Date, instrumentUid?: string) => { + const targets = accounts.flatMap((account) => + from < to + ? [ + { + accountId: account.accountId, + from, + to, + ...(instrumentUid ? { instrumentUid } : {}), + }, + ] + : [], + ); + sources.push( + ...operationIntervals(targets).map((range) => ({ + ...range, + kind: 'operations' as const, + priority: 'screen' as const, + })), + ); + }; + const portfolio = () => + sources.push( + ...accounts.map(({ accountId }) => ({ + kind: 'portfolio' as const, + accountId, + priority: 'screen' as const, + })), + ); + const knownOpening = + accounts.length && accounts.every((account) => account.openedOn) + ? new Date( + `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, + ) + : undefined; + if (!context.membershipKnown) return { sources, unavailable, now }; + if ( + ['accounts', 'account', 'events', 'stock', 'bond'].includes(screen.screen) + ) + portfolio(); + if ( + screen.screen === 'operations' || + screen.screen === 'stock' || + screen.screen === 'bond' + ) { + const range = ledgerRange( + screen.from, + screen.to, + screen.instrumentUid, + accounts, + now, + ); + operations(range.from, range.to, force ? screen.instrumentUid : undefined); + } + if (screen.screen === 'account' || screen.screen === 'income') { + const period = planIncomePeriod( + screen.screen === 'income' ? screen.from : undefined, + screen.screen === 'income' ? screen.to : undefined, + knownOpening, + ); + for (const range of [ + period.selected, + ...(screen.screen === 'income' + ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] + : []), + ]) { + const bounds = dateOnlyBounds(range); + operations( + bounds.from, + new Date(Math.min(bounds.to.valueOf() + 1, now.valueOf())), + ); + } + if (screen.screen === 'income' && !period.yearAgo) + unavailable.push({ + resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier + reason: 'calendar_or_account_boundary', + }); + if (screen.screen === 'account') { + const range = ledgerRange( + screen.from, + screen.to, + undefined, + accounts, + now, + ); + operations(range.from, range.to); + } + } + if (screen.screen === 'stocks' || screen.screen === 'bonds') + sources.push({ + kind: 'catalog', + instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND', + query: screen.query?.trim().toUpperCase() ?? '', + priority: 'screen', + }); + const instruments = context.instruments.filter( + (instrument) => + !screen.instrumentUid || instrument.uid === screen.instrumentUid, + ); + if ( + screen.instrumentUid && + !instruments.length && + (screen.screen === 'stock' || screen.screen === 'bond') + ) + instruments.push({ + uid: screen.instrumentUid, + type: screen.screen === 'stock' ? 'STOCK' : 'BOND', + historyFrom: null, + }); + for (const instrument of instruments) { + const identity = { + instrumentUid: instrument.uid, + instrumentType: instrument.type, + }; + sources.push({ ...identity, kind: 'metadata', priority: 'screen' }); + if (['stocks', 'bonds', 'stock', 'bond'].includes(screen.screen)) + sources.push({ ...identity, kind: 'quote', priority: 'screen' }); + if (screen.screen === 'stock' || screen.screen === 'bond') { + sources.push({ + ...identity, + kind: screen.screen === 'stock' ? 'company' : 'metrics', + priority: 'screen', + }); + const from = new Date(now); + const period = screen.candlePeriod ?? 'month'; + if (period !== 'all') + from.setUTCMonth(from.getUTCMonth() - candleMonths[period]); + const range = + screen.candleRange ?? + (period === 'all' + ? instrument.historyFrom + ? { from: instrument.historyFrom, to: now } + : null + : { from, to: now }); + if (range) + sources.push({ + ...identity, + kind: 'candles', + ...range, + priority: 'screen', + }); + else + unavailable.push({ + resourceKey: `candles/${instrument.uid}/all`, + reason: 'history_start_unavailable', + }); + } + if (['account', 'events', 'stock', 'bond'].includes(screen.screen)) { + const day = + screen.screen === 'stock' || screen.screen === 'bond' + ? moscowDate(now) + : now.toISOString().slice(0, 10); + const from = + screen.screen === 'events' && screen.from + ? new Date(`${screen.from}T00:00:00Z`) + : new Date(`${day}T00:00:00Z`); + const to = + screen.screen === 'events' && screen.to + ? new Date(`${screen.to}T00:00:00Z`) + : new Date(from); + if ( + !(screen.screen === 'events' && screen.to) && + screen.screen !== 'stock' && + screen.screen !== 'bond' + ) + to.setUTCDate(to.getUTCDate() + defaultEventDays); + if (screen.screen === 'stock' || screen.screen === 'bond') { + from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears); + to.setUTCFullYear(to.getUTCFullYear() + 1); + } + sources.push({ + ...identity, + kind: 'calendar', + from, + to, + priority: 'screen', + }); + } + if ( + !force && + ['account', 'operations', 'income', 'events'].includes(screen.screen) + ) { + if (instrument.historyFrom) + sources.push({ + ...identity, + kind: 'candles', + from: instrument.historyFrom, + to: now, + priority: 'history', + }); + else + unavailable.push({ + resourceKey: `candles/${instrument.uid}/all`, + reason: 'history_start_unavailable', + }); + } + } + if (!force && !['stocks', 'bonds'].includes(screen.screen)) + sources.push( + ...accountHistory(accounts, now).map((range) => ({ + ...range, + kind: 'operations' as const, + priority: 'history' as const, + })), + ); + const priorities = { discovery: 0, screen: 1, history: 2 }; + sources.sort( + (left, right) => priorities[left.priority] - priorities[right.priority], + ); + return { sources, unavailable, now }; +} + +export function accountHistory( + accounts: Account[], + now: Date, +): OperationInterval[] { + return accounts.flatMap((account) => { + const from = new Date( + account.openedOn + ? `${account.openedOn}T00:00:00Z` + : '1970-01-01T00:00:00Z', + ); + return from < now ? [{ accountId: account.accountId, from, to: now }] : []; + }); +} + +export function ledgerRange( + fromValue: string | undefined, + toValue: string | undefined, + instrumentUid: string | undefined, + accounts: Account[], + now: Date, +) { + const opening = accounts.every((account) => account.openedOn) + ? accounts.map((account) => account.openedOn ?? '').sort()[0] + : undefined; + const from = fromValue + ? new Date(fromValue) + : instrumentUid + ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') + : new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); + const end = toValue + ? new Date( + /^\d{4}-\d{2}-\d{2}$/.test(toValue) + ? `${toValue}T23:59:59.999Z` + : toValue, + ) + : now; + return { + from, + to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())), + }; +} diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts new file mode 100644 index 0000000..cb37443 --- /dev/null +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -0,0 +1,294 @@ +import { accountHistory } from '../../investment-sync-plan'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { + TbankAccessService, + TbankClient, +} from '../../../../integrations/tbank/tbank.api'; +import { + BrokerageOperationsRepository, + type OperationAttempt, +} from '../infrastructure/brokerage-operations.repository'; +import { + operationIntervals, + planOperationRead, + type OperationInterval, + uncoveredOperationIntervals, +} from '../domain/operations-sync-plan'; + +const operationsPageSize = 1000; +const priorities = { screen: 1, history: 2 } as const; + +@Injectable() +export class BrokerageOperationsSyncService { + private readonly processRunId = randomUUID(); + private readonly runningAttempts = new Set(); + private readonly ready = new Map< + string, + { attempt: OperationAttempt; priority: number } + >(); + private draining = false; + // Only short DB reservations/publications are serialized, never RPCs. + private reservations: Promise = Promise.resolve(); + + constructor( + private readonly repository: BrokerageOperationsRepository, + private readonly access: TbankAccessService, + private readonly tbank: TbankClient, + private readonly logger: StructuredLogger, + ) {} + + async readPlan( + userId: string, + intervals: OperationInterval[], + options: { + movingEnd?: boolean; + force?: boolean; + observe?: boolean; + history?: boolean; + revision?: string; + } = {}, + ) { + const connection = await this.access.local(userId); + if ( + !connection.membershipAt || + (options.revision && options.revision !== connection.revision) + ) + return { plan: null, handle: null }; + if ( + intervals.some( + (interval) => + !connection.accounts.some( + (account) => account.accountId === interval.accountId, + ), + ) + ) + throw new NotFoundException(); + const coverage = await this.repository.coverage( + userId, + connection.revision, + intervals.map((interval) => interval.accountId), + ); + const now = new Date(); + const movingTo = Math.max( + ...intervals.map((interval) => interval.to.valueOf()), + ); + const targets = intervals.map((interval) => + options.movingEnd && interval.to.valueOf() === movingTo + ? { ...interval, to: now } + : interval, + ); + const plan = planOperationRead(targets, coverage, now, options); + const complete = plan.intervals.every( + (interval) => interval.availability === 'complete', + ); + const handle = + !options.observe && plan.load.length + ? await this.request( + userId, + plan.load, + complete || Boolean(options.force), + 'screen', + connection.revision, + ) + : null; + if (!options.observe && !options.force && options.history) { + const accountIds = new Set( + intervals.map((interval) => interval.accountId), + ); + const history = accountHistory( + connection.accounts.filter((account) => + accountIds.has(account.accountId), + ), + now, + ); + const missing = planOperationRead(history, coverage, now, { + movingEnd: true, + }).load; + const remaining = uncoveredOperationIntervals(missing, targets); + if (remaining.length) + await this.request( + userId, + remaining, + false, + 'history', + connection.revision, + ); + } + return { plan, handle }; + } + + async request( + userId: string, + intervals: OperationInterval[], + atomic: boolean, + priority: keyof typeof priorities = 'screen', + revision?: string, + ): Promise { + const connection = await this.access.local(userId); + if ( + connection.membershipAt && + intervals.some( + (interval) => + !connection.accounts.some( + (account) => account.accountId === interval.accountId, + ), + ) + ) + throw new NotFoundException(); + if (revision && revision !== connection.revision) return null; + const cutoff = new Date(); + const bounded = operationIntervals(intervals) + .map((interval) => ({ + ...interval, + to: new Date(Math.min(interval.to.valueOf(), cutoff.valueOf())), + })) + .filter((interval) => interval.from < interval.to); + if (!bounded.length) return null; + const reserve = this.reservations.then(async () => { + const requested = await this.repository.request( + userId, + connection.revision, + operationIntervals(bounded), + this.processRunId, + atomic, + this.runningAttempts, + priorities[priority], + ); + if (!requested) return null; + const { id, attemptId, revision } = requested.work; + this.runningAttempts.add(attemptId); + this.ready.set(attemptId, { + attempt: { id, attemptId, userId, revision }, + priority: requested.work.priority, + }); + if (!this.draining) { + this.draining = true; + // Startup and observation never enter this path. + setImmediate(() => { + void this.drain(); + }); + } + return id; + }); + this.reservations = reserve.catch(() => undefined); + return reserve; + } + + async observe(userId: string, id: string) { + const connection = await this.access.local(userId); + const work = await this.repository.observe(userId, connection.revision, id); + if (!work) throw new NotFoundException(); + return work; + } + + private async drain(): Promise { + try { + while (this.ready.size) { + const selected = [...this.ready.values()].sort( + (a, b) => a.priority - b.priority, + )[0]; + const { attempt } = selected; + this.ready.delete(attempt.attemptId); + let pending = false; + try { + pending = await this.step(attempt); + } catch { + this.logger.event( + 'error', + 'tbank.operations.sync.persistence_failed', + { userId: attempt.userId, code: 'TBANK_UNAVAILABLE' }, + ); + } + const next = pending ? await this.repository.pending(attempt) : null; + if (next) { + const joined = this.ready.get(attempt.attemptId); + this.ready.set(attempt.attemptId, { + attempt, + priority: Math.min( + next.priority, + joined?.priority ?? next.priority, + ), + }); + } else { + this.ready.delete(attempt.attemptId); + this.runningAttempts.delete(attempt.attemptId); + } + } + } finally { + this.draining = false; + } + } + + private async step(attempt: OperationAttempt): Promise { + try { + const plan = await this.repository.pending(attempt); + if (!plan) return false; + if (!plan.next) { + const publish = this.reservations.then(async () => { + const current = await this.repository.pending(attempt); + if (!current) return false; + if (current.next) return true; + const published = await this.repository.publish( + attempt, + current.intervals, + new Date(), + true, + ); + return published && Boolean(await this.repository.pending(attempt)); + }); + this.reservations = publish.catch(() => undefined); + return await publish; + } + const { interval, cursor } = plan.next; + const token = await this.access.tokenForAttempt(attempt); + if (!token) return false; + const response = await this.tbank.getOperationsByCursor(token, { + accountId: interval.accountId, + // Upstream inclusivity is not assumed; storage selects exact [from,to). + from: { + seconds: String( + Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - 1, + ), + }, + to: { + seconds: String( + Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1, + ), + }, + ...(interval.instrumentUid + ? { instrumentId: interval.instrumentUid } + : {}), + cursor, + limit: operationsPageSize, + state: 'OPERATION_STATE_EXECUTED', + }); + if ( + !(await this.repository.stagePage(attempt, interval, cursor, response)) + ) + return false; + if (!plan.atomic && !response.hasNext) { + if ( + !(await this.repository.publish( + attempt, + [interval], + new Date(), + true, + )) + ) + return false; + } + // One page per turn: newly requested screen work can overtake backfill. + // TbankClient alone owns the bounded RPC queue and retries. + return true; + } catch (error) { + await this.repository.fail( + attempt, + error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', + ); + return false; + } + } +} diff --git a/src/modules/investments/operations/application/brokerage-operations.service.spec.ts b/src/modules/investments/operations/application/brokerage-operations.service.spec.ts deleted file mode 100644 index 9328fc3..0000000 --- a/src/modules/investments/operations/application/brokerage-operations.service.spec.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { - BadRequestException, - ConflictException, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; - -jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({ - TbankAccessService: class TbankAccessService {}, -})); - -import type { TbankAccountContext } from '../../../../integrations/tbank/tbank-access.service'; -import { BrokerageOperationsService } from './brokerage-operations.service'; - -describe('BrokerageOperationsService', () => { - const access = { withAccount: jest.fn() }; - const tbank = { - getOperationsByCursor: jest.fn(), - }; - const requestContext = { set: jest.fn() }; - const metadata = { resolve: jest.fn() }; - const service = new BrokerageOperationsService( - access as never, - tbank as never, - requestContext as never, - metadata as never, - ); - let operationsRequest: unknown; - let generation: string; - - beforeEach(() => { - jest.clearAllMocks(); - access.withAccount.mockReset(); - tbank.getOperationsByCursor.mockReset(); - metadata.resolve.mockReset(); - generation = 'generation-1'; - access.withAccount.mockImplementation( - ( - _userId: string, - _accountId: string, - handler: (context: TbankAccountContext) => Promise, - ) => - handler({ - token: 'token', - generation, - account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' }, - }), - ); - tbank.getOperationsByCursor.mockImplementation( - (_token: string, request: unknown) => { - operationsRequest = request; - return Promise.resolve({ items: [] }); - }, - ); - (service as unknown as { pages: Map }).pages.clear(); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - }); - - it('uses the current UTC year by default and asks only for executed operations', async () => { - await service.get('user', 'account', {}); - - const request = operationsRequest as { - state: string; - from: { seconds: string }; - }; - expect(request.state).toBe('OPERATION_STATE_EXECUTED'); - expect(request.from.seconds).toBe( - String(Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000)), - ); - }); - - it('uses inclusive date boundaries, filters categories, and preserves cursors', async () => { - tbank.getOperationsByCursor.mockResolvedValue({ - hasNext: true, - nextCursor: 'next', - items: [ - { id: 'income', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON' }, - { - id: 'fee', - date: { seconds: '2' }, - type: 'OPERATION_TYPE_BROKER_FEE', - }, - ], - }); - - await expect( - service.get('user', 'account', { - from: '2025-01-01', - to: '2025-01-31', - categories: ['income'], - cursor: 'previous', - limit: 10, - }), - ).resolves.toMatchObject({ - items: [{ id: 'income' }], - nextCursor: 'next', - hasNext: true, - }); - expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( - 'token', - expect.objectContaining({ - from: { seconds: '1735689600' }, - to: { seconds: '1738367999', nanos: 999_999_999 }, - cursor: 'previous', - }), - ); - }); - - it('includes resolved instrument logo metadata in a ledger entry', async () => { - tbank.getOperationsByCursor.mockResolvedValue({ - items: [ - { - id: 'coupon', - date: { seconds: '1' }, - type: 'OPERATION_TYPE_COUPON', - instrumentUid: 'instrument-uid', - }, - ], - }); - metadata.resolve.mockResolvedValue({ - brand: { logoName: 'instrument-logo.png' }, - }); - - await expect(service.get('user', 'account', {})).resolves.toMatchObject({ - items: [{ id: 'coupon', logoName: 'instrument-logo.png' }], - }); - }); - - it('resolves repeated instrument metadata only once per ledger page', async () => { - tbank.getOperationsByCursor.mockResolvedValue({ - items: [ - { - id: 'first', - date: { seconds: '1' }, - type: 'OPERATION_TYPE_COUPON', - instrumentUid: 'instrument-uid', - }, - { - id: 'second', - date: { seconds: '2' }, - type: 'OPERATION_TYPE_COUPON', - instrumentUid: 'instrument-uid', - }, - ], - }); - metadata.resolve.mockResolvedValue({ - brand: { logoName: 'instrument-logo.png' }, - }); - - await service.get('user', 'account', {}); - - expect(metadata.resolve).toHaveBeenCalledTimes(1); - }); - - it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => { - tbank.getOperationsByCursor.mockResolvedValue({ - items: [ - { - id: 'first', - date: { seconds: '1' }, - type: 'OPERATION_TYPE_COUPON', - instrumentUid: 'instrument-uid', - }, - { - id: 'second', - date: { seconds: '2' }, - type: 'OPERATION_TYPE_COUPON', - instrumentUid: 'instrument-uid', - figi: 'BBG000000001', - }, - ], - }); - metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({ - brand: { logoName: 'fallback-logo.png' }, - }); - - await expect(service.get('user', 'account', {})).resolves.toMatchObject({ - items: [ - { id: 'first', logoName: null }, - { id: 'second', logoName: 'fallback-logo.png' }, - ], - }); - expect(metadata.resolve).toHaveBeenNthCalledWith( - 1, - 'token', - 'instrument-uid', - null, - ); - expect(metadata.resolve).toHaveBeenNthCalledWith( - 2, - 'token', - 'instrument-uid', - 'BBG000000001', - ); - }); - - it('returns an empty page without calling T-Bank when the period starts in the future', async () => { - await expect( - service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }), - ).resolves.toMatchObject({ - accountId: 'account', - items: [], - nextCursor: null, - hasNext: false, - }); - expect(tbank.getOperationsByCursor).not.toHaveBeenCalled(); - }); - - it('rejects inaccessible accounts before reading operations', async () => { - access.withAccount.mockRejectedValueOnce(new NotFoundException()); - - await expect(service.get('user', 'account', {})).rejects.toEqual( - expect.any(NotFoundException), - ); - expect(tbank.getOperationsByCursor).not.toHaveBeenCalled(); - }); - - it('retains existing T-Bank error semantics', async () => { - access.withAccount.mockRejectedValueOnce( - new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }), - ); - await expect(service.get('user', 'account', {})).rejects.toEqual( - expect.any(BadRequestException), - ); - tbank.getOperationsByCursor.mockRejectedValueOnce( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - await expect(service.get('user', 'account', {})).rejects.toEqual( - expect.any(ServiceUnavailableException), - ); - }); - - it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z')); - - await service.get('user', 'account', { - categories: ['tax', 'income', 'income'], - }); - jest.advanceTimersByTime(500); - await service.get('user', 'account', { categories: ['income', 'tax'] }); - - expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1); - }); - - it('does not let a cached page bypass account ownership checks', async () => { - const query = { from: '2025-01-01', to: '2025-01-31' }; - await service.get('user', 'account', query); - access.withAccount.mockRejectedValueOnce(new NotFoundException()); - - await expect(service.get('user', 'account', query)).rejects.toEqual( - expect.any(NotFoundException), - ); - expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1); - }); - - it('does not reuse a cached ledger page across connection generations', async () => { - const query = { from: '2025-01-01', to: '2025-01-31' }; - - await service.get('user', 'account', query); - await service.get('user', 'account', query); - generation = 'generation-2'; - await service.get('user', 'account', query); - - expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2); - }); - - it('does not reuse a cached ledger page after the connection is deleted', async () => { - const query = { from: '2025-01-01', to: '2025-01-31' }; - await service.get('user', 'account', query); - access.withAccount.mockRejectedValueOnce( - new ConflictException({ code: 'TBANK_NOT_CONNECTED' }), - ); - - await expect(service.get('user', 'account', query)).rejects.toEqual( - expect.any(ConflictException), - ); - expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1); - }); - - it('keeps a late previous-generation page unavailable to ledger and income collection', async () => { - let finishPrevious: ((value: unknown) => void) | undefined; - const previous = new Promise((resolve) => { - finishPrevious = resolve; - }); - tbank.getOperationsByCursor - .mockReturnValueOnce(previous) - .mockResolvedValueOnce({ - items: [operation('current')], - }); - const range = { from: '2025-01-01', to: '2025-01-31' }; - - const previousRequest = service.collectRanges('user', 'account', [range]); - generation = 'generation-2'; - const current = await service.collectRanges('user', 'account', [range]); - finishPrevious?.({ items: [operation('previous')] }); - await previousRequest; - const reused = await service.collectRanges('user', 'account', [range]); - - expect(current.pages[0][0].items[0].id).toBe('current'); - expect(reused.pages[0][0].items[0].id).toBe('current'); - expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2); - }); - - it('collects every cursor page and rejects a malformed continuation cursor', async () => { - tbank.getOperationsByCursor - .mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] }) - .mockResolvedValueOnce({ hasNext: false, items: [] }); - - await expect( - service.collectRanges('user', 'account', [ - { from: '2026-01-01', to: '2026-01-31' }, - ]), - ).resolves.toMatchObject({ - pages: [[{ hasNext: true }, { hasNext: false }]], - }); - - tbank.getOperationsByCursor.mockReset(); - tbank.getOperationsByCursor.mockResolvedValue({ - hasNext: true, - nextCursor: 'same', - items: [], - }); - await expect( - service.collectRanges('user', 'account', [ - { from: '2026-02-01', to: '2026-02-28' }, - ]), - ).rejects.toMatchObject({ response: { code: 'TBANK_UNAVAILABLE' } }); - }); - - it('collects an in-progress Moscow day only through the current instant', async () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z')); - - await service.collectRanges('user', 'account', [ - { from: '2026-08-01', to: '2026-08-28' }, - ]); - - expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( - 'token', - expect.objectContaining({ - from: { seconds: '1785531600' }, - to: { seconds: '1787867058' }, - limit: 1000, - }), - ); - }); - - it('evicts the least recently used ledger page after one hundred entries', async () => { - for (let index = 0; index < 100; index += 1) - await service.get('user', 'account', { cursor: String(index) }); - - await service.get('user', 'account', { cursor: '0' }); - await service.get('user', 'account', { cursor: '100' }); - await service.get('user', 'account', { cursor: '1' }); - - const pages = (service as unknown as { pages: { size: number } }).pages; - expect(pages.size).toBe(100); - expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102); - }); -}); - -function operation(id: string) { - return { - id, - date: { seconds: '1' }, - type: 'OPERATION_TYPE_COUPON', - }; -} diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 8b4270a..5db1783 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -1,518 +1,636 @@ -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { - BadRequestException, - Injectable, - ServiceUnavailableException, -} from '@nestjs/common'; -import { LRUCache } from 'lru-cache'; -import { randomUUID } from 'node:crypto'; -import { RequestContextService } from '../../../../common/logging/request-context.service'; + ledgerRange, + accountHistory, + planInvestmentScreen, +} from '../../investment-sync-plan'; +import { ConfigService } from '@nestjs/config'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository'; +import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; +import { + planOperationRead, + uncoveredOperationIntervals, + operationIntervalsOverlap, +} from '../domain/operations-sync-plan'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; import { InstrumentMetadataService, + metadataKey, TbankAccessService, - TbankClient, + TbankMembershipService, + tbankSourceTtlMs, + type TbankOperationItem, } from '../../../../integrations/tbank/tbank.api'; -import { AccountReferenceDto } from '../../account-reference.dto'; import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar'; import { - BrokerageOperationDto, DEFAULT_OPERATIONS_PAGE_SIZE, - BrokerageOperationsPageDto, BrokerageOperationsQueryDto, } from '../presentation/brokerage-operation.dto'; -import { - mapBrokerageOperation, - operationCategory, -} from '../infrastructure/brokerage-operation.mapper'; +import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper'; -const CONTINUATION_TTL_MS = 300_000; - -type OperationStream = { - account: AccountReferenceDto; - pending: BrokerageOperationDto[]; - cursor?: string; - finished: boolean; - seen: string[]; - lastOccurredAt?: number; +type LocalOperationsCursor = { + movingEnd?: boolean; + binding: string; + to: string; + at: string; + accountId: string; + id: string; }; -type AllOperationsContinuation = { - signature: string; - range: { from: Date; to: Date }; - streams: OperationStream[]; - page?: Promise; -}; - -const freshForMs = 30_000; -const maxCachedPages = 100; @Injectable() export class BrokerageOperationsService { - private readonly pages = new LRUCache({ - max: maxCachedPages, - ttl: freshForMs, - ttlAutopurge: true, - }); - - private readonly continuations = new LRUCache< - string, - AllOperationsContinuation - >({ - max: 100, - ttl: CONTINUATION_TTL_MS, - ttlAutopurge: true, - }); - constructor( private readonly access: TbankAccessService, - private readonly tbank: TbankClient, - private readonly requestContext: RequestContextService, private readonly metadata: InstrumentMetadataService, + private readonly repository: BrokerageOperationsRepository, + private readonly sync: BrokerageOperationsSyncService, + private readonly membership: TbankMembershipService, + private readonly config: ConfigService, ) {} - async get( + async read( userId: string, accountId: string, query: BrokerageOperationsQueryDto, - ): Promise { - this.requestContext.set({ userId, brokerageAccountId: accountId }); - const range = this.range(query); - - return this.access.withAccount( - userId, + ) { + const connection = await this.access.local(userId, accountId); + const cursor = query.cursor ? this.decodeCursor(query.cursor) : undefined; + const selected = connection.accounts.filter( + (account) => accountId === 'all' || account.accountId === accountId, + ); + const now = new Date(); + const range = ledgerRange( + query.from, + query.to, + query.instrumentUid, + selected, + now, + ); + const from = range.from; + const to = cursor ? new Date(cursor.to) : range.to; + const movingEnd = cursor?.movingEnd ?? range.to.valueOf() === now.valueOf(); + const categories = [...new Set(query.categories ?? [])].sort(); + const limit = query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE; + const filterKey = JSON.stringify([ accountId, - ({ token, generation, account }) => - this.page( - token, - generation, - userId, - accountId, - query, - query.instrumentUid && !query.from - ? { - ...range, - from: new Date( - Number(account.openedDate?.seconds ?? 0) * - MILLISECONDS_PER_SECOND, - ), - } - : range, - ), - ); - } - - async getAll( - userId: string, - query: BrokerageOperationsQueryDto, - ): Promise { - return this.access.withSupportedAccounts( + from.toISOString(), + query.to + ? new Date( + /^\d{4}-\d{2}-\d{2}$/.test(query.to) + ? `${query.to}T23:59:59.999Z` + : query.to, + ).toISOString() + : 'now', + query.instrumentUid ?? null, + categories, + limit, + ]); + const readInput = { userId, - async ({ token, generation, accounts }) => { - const signature = JSON.stringify({ - userId, - generation, - accounts: accounts.map((account) => account.id).sort(), - instrumentUid: query.instrumentUid ?? null, - from: query.from ?? null, - to: query.to ?? null, - categories: [...new Set(query.categories ?? [])].sort(), - limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE, - }); - const continuation = query.cursor - ? this.continuations.get(query.cursor) - : undefined; - if ( - query.cursor && - (!continuation || continuation.signature !== signature) - ) - throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); - if (continuation?.page) return continuation.page; - const oldest = accounts - .map((account) => account.openedOn) - .filter((date): date is string => Boolean(date)) - .sort()[0]; - const range = - continuation?.range ?? - this.range({ - ...query, - from: - query.from ?? - (query.instrumentUid - ? ((accounts.every((account) => account.openedOn) - ? oldest - : undefined) ?? '1970-01-01') - : undefined), - }); - const streams: OperationStream[] = continuation - ? continuation.streams.map((stream) => ({ - ...stream, - pending: [...stream.pending], - seen: [...stream.seen], - })) - : accounts.map(({ id, name }) => ({ - account: { id, name }, - pending: [], - finished: false, - seen: [], - })); - const result = this.mergePage( - token, - generation, - userId, - query, - signature, - range, - streams, - ); - if (continuation) continuation.page = result; - try { - return await result; - } catch (error) { - if (continuation) continuation.page = undefined; - throw error; - } + revision: connection.revision, + accountId, + from, + to, + instrumentUid: query.instrumentUid, + categories, + limit, + filterKey, + cursor, + }; + if (cursor) await this.repository.readPage(readInput); + const screen = planInvestmentScreen( + { + screen: 'operations', + accountId, + from: from.toISOString(), + to: new Date(to.valueOf() - 1).toISOString(), }, + { + accounts: selected, + instruments: [], + membershipKnown: Boolean(connection.membershipAt), + }, + now, ); - } - - private async mergePage( - token: string, - generation: string, - userId: string, - query: BrokerageOperationsQueryDto, - signature: string, - range: { from: Date; to: Date }, - streams: OperationStream[], - ): Promise { - const items: BrokerageOperationDto[] = []; - // Each stream retains unconsumed rows. A page boundary never discards them. - while (items.length < (query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE)) { - for (const stream of streams) { - while (!stream.pending.length && !stream.finished) { - const page = await this.page( - token, - generation, - userId, - stream.account.id, - { ...query, cursor: stream.cursor }, - range, - ); - for (const item of page.items) { - const time = Date.parse(item.occurredAt); - if ( - !Number.isFinite(time) || - (stream.lastOccurredAt !== undefined && - time > stream.lastOccurredAt) - ) - throw new ServiceUnavailableException({ - code: 'TBANK_UNAVAILABLE', - }); - stream.lastOccurredAt = time; - } - if (page.hasNext) { - if (!page.nextCursor || stream.seen.includes(page.nextCursor)) - throw new ServiceUnavailableException({ - code: 'TBANK_UNAVAILABLE', - }); - stream.seen.push(page.nextCursor); - stream.cursor = page.nextCursor; - } else stream.finished = true; - stream.pending = page.items.map((item) => ({ - ...item, - account: stream.account, - })); - } + const intervals = screen.sources.flatMap((source) => + source.kind === 'operations' && + source.priority === 'screen' && + source.accountId && + source.from && + source.to + ? [{ accountId: source.accountId, from: source.from, to: source.to }] + : [], + ); + if (query.sync !== 'observe') { + const discovery = await this.membership.request( + userId, + connection.revision, + ); + if (discovery) { + void discovery.done + .then(async () => { + const current = await this.access.local(userId, accountId); + if (current.revision !== connection.revision) return; + const currentRange = ledgerRange( + query.from, + query.to, + query.instrumentUid, + current.accounts.filter( + (account) => + accountId === 'all' || account.accountId === accountId, + ), + now, + ); + const targets = current.accounts + .filter( + (account) => + accountId === 'all' || account.accountId === accountId, + ) + .flatMap((account) => + currentRange.from < currentRange.to + ? [{ accountId: account.accountId, ...currentRange }] + : [], + ); + await this.sync.readPlan(userId, targets, { + movingEnd, + history: true, + revision: connection.revision, + }); + }) + .catch(() => undefined); + } else if (intervals.length) { + await this.sync.readPlan(userId, intervals, { + movingEnd, + history: true, + revision: connection.revision, + }); } - const next = streams - .filter((stream) => stream.pending.length) - .sort( - (left, right) => - Date.parse(right.pending[0].occurredAt) - - Date.parse(left.pending[0].occurredAt) || - left.account.id.localeCompare(right.account.id), - )[0]; - if (!next) break; - items.push(...next.pending.splice(0, 1)); } - const hasNext = streams.some( - (stream) => stream.pending.length || !stream.finished, + const saved = await this.repository.readPage(readInput); + const accounts = saved?.connection.accounts ?? []; + const targets = accounts.flatMap((account) => + from < to + ? [ + { + accountId: account.accountId, + from, + to, + instrumentUid: query.instrumentUid, + }, + ] + : [], ); - const nextCursor = hasNext ? randomUUID() : null; - if (nextCursor) - this.continuations.set(nextCursor, { signature, range, streams }); + const plan = planOperationRead(targets, saved?.coverage ?? [], now, { + movingEnd, + }); + const confirmed = plan.intervals.flatMap((target) => + (saved?.coverage ?? []) + .filter( + (row) => + row.accountId === target.accountId && + (!row.instrumentUid || + row.instrumentUid === target.instrumentUid) && + row.from < target.to && + target.from < row.to, + ) + .map((row) => ({ + accountId: target.accountId, + from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())), + to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())), + })), + ); + const missing = uncoveredOperationIntervals( + plan.intervals, + confirmed.map((range) => ({ + ...range, + instrumentUid: query.instrumentUid, + })), + ); + const available = Boolean(saved?.connection.membershipAt); + const complete = available && !missing.length; + const relevant = + saved?.work.filter((work) => + work.intervals.some((interval) => + targets.some((target) => operationIntervalsOverlap(interval, target)), + ), + ) ?? []; + const work = + relevant.find((work) => work.state === 'running') ?? + relevant.find((work) => work.state === 'failed') ?? + null; + const sources = (saved?.coverage ?? []).filter((row) => + targets.some( + (target) => + operationIntervalsOverlap(row, target) && + (!row.instrumentUid || row.instrumentUid === target.instrumentUid), + ), + ); + const lastSuccessfulAt = + sources + .map((row) => row.fetchedAt) + .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? + (complete ? saved?.connection.membershipAt : null) ?? + null; + const serialize = (range: { accountId: string; from: Date; to: Date }) => ({ + accountId: range.accountId, + from: range.from.toISOString(), + to: range.to.toISOString(), + }); + const historyTargets = accountHistory(accounts, now); + const history = planOperationRead( + historyTargets, + saved?.coverage ?? [], + now, + { movingEnd: true }, + ); + const knownBoundary = accounts.every( + (account) => account.openedOn !== null, + ); + const rows = saved?.rows.slice(0, limit) ?? []; + const instruments = await this.metadata.savedMany( + rows.map((row) => row.payload as TbankOperationItem), + ); + const metadataDependencies = [...instruments] + .filter(([, value]) => value.complete) + .map(([key, value]) => + investmentResourceMeta({ + resourceKey: `metadata/${key}`, + dataRevision: value.dataRevision, + availability: 'complete', + fetchedAt: value.fetchedAt, + freshForMs: tbankSourceTtlMs.metadata, + work: null, + }), + ); + if (query.sync !== 'observe') { + const identities = new Map( + rows.map((row) => { + const identity = row.payload as TbankOperationItem; + return [ + metadataKey(identity), + { instrumentUid: identity.instrumentUid, figi: identity.figi }, + ] as const; + }), + ); + for (const identity of identities.values()) + if (!instruments.get(metadataKey(identity))?.fresh) + await this.metadata.request(userId, connection.revision, identity); + } + const membershipMeta = investmentResourceMeta({ + resourceKey: 'accounts/membership', + dataRevision: String(saved?.connection.membershipRevision ?? 0), + availability: available ? 'complete' : 'missing', + fetchedAt: saved?.connection.membershipAt ?? null, + freshForMs: tbankSourceTtlMs.membership, + work: saved?.membershipWork ?? null, + }); + const meta = investmentResourceMeta({ + resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`, + dataRevision: saved?.binding ?? 'missing', + availability: !available + ? 'missing' + : complete + ? 'complete' + : confirmed.length + ? 'partial' + : 'missing', + fetchedAt: lastSuccessfulAt, + freshForMs: plan.intervals.some((interval) => interval.stale) + ? 0 + : Number.POSITIVE_INFINITY, + work: work ?? saved?.membershipWork ?? null, + dependencies: [membershipMeta, ...metadataDependencies], + coverage: { + requested: targets.map(serialize), + completeRanges: confirmed.map(serialize), + missingRanges: missing.map(serialize), + asOf: + accounts.length && + accounts.every((account) => + plan.intervals.some( + (interval) => + interval.accountId === account.accountId && interval.asOf, + ), + ) + ? new Date( + Math.min( + ...accounts.map((account) => + Math.max( + ...plan.intervals + .filter( + (interval) => + interval.accountId === account.accountId, + ) + .map((interval) => interval.asOf?.valueOf() ?? 0), + ), + ), + ), + ).toISOString() + : null, + historyBoundary: knownBoundary ? 'known' : 'unknown', + historyState: saved?.work.some( + (work) => work.state === 'running' && work.priority > 1, + ) + ? 'loading' + : saved?.work.some( + (work) => work.state === 'failed' && work.priority > 1, + ) + ? 'failed' + : knownBoundary && + history.intervals.every( + (interval) => interval.availability === 'complete', + ) + ? 'complete' + : saved?.coverage.length + ? 'partial' + : 'missing', + }, + }); + const last = rows.at(-1); + const hasNext = Boolean(saved && saved.rows.length > limit); return { - accountId: 'all', - items, - hasNext, - nextCursor, - observedAt: new Date().toISOString(), + data: + meta.availability === 'missing' + ? null + : { + accountId, + items: rows.map((row) => ({ + ...mapBrokerageOperation(row.payload as TbankOperationItem), + ...(accountId === 'all' + ? { + account: { + id: row.accountId, + name: + accounts.find( + (account) => account.accountId === row.accountId, + )?.name ?? row.accountId, + }, + } + : {}), + logoName: + instruments + .get(metadataKey(row.payload as TbankOperationItem)) + ?.data?.brand?.logoName?.trim() || null, + })), + hasNext, + nextCursor: + hasNext && last && saved + ? this.encodeCursor({ + binding: saved.binding, + movingEnd, + to: to.toISOString(), + at: last.occurredAt.toISOString(), + accountId: last.accountId, + id: last.brokerOperationId, + }) + : null, + observedAt: now.toISOString(), + }, + meta, }; } - private async collectAllRanges( - userId: string, - ranges: - | DateOnlyRange[] - | ((accountOpenedAt: Date | undefined) => DateOnlyRange[]), - ): Promise<{ - accountOpenedAt: Date | undefined; - pages: BrokerageOperationsPageDto[][]; - }> { - return this.access.withSupportedAccounts( - userId, - async ({ token, generation, accounts }) => { - // The current cohort includes newly opened accounts with no earlier receipts. - const oldest = accounts - .map((account) => account.openedOn) - .filter((date): date is string => Boolean(date)) - .sort()[0]; - const accountOpenedAt = - oldest && accounts.every((account) => account.openedOn) - ? new Date(`${oldest}T00:00:00+03:00`) - : undefined; - const selected = - typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges; - const pages: { - items: (BrokerageOperationDto & { account: AccountReferenceDto })[]; - }[][] = selected.map(() => []); - for (const account of accounts) { - for (const [index, range] of selected.entries()) { - const collected = await this.collectRange( - token, - generation, - userId, - account.id, - range, - ); - pages[index].push( - ...collected.map((page) => ({ - ...page, - items: page.items.map((item) => ({ - ...item, - account: { id: account.id, name: account.name }, - })), - })), - ); - } - } - return { - accountOpenedAt, - pages: pages.map((group) => [ - { - ...emptyPage('all'), - items: group - .flatMap((page) => page.items) - .sort( - (left, right) => - Date.parse(right.occurredAt) - - Date.parse(left.occurredAt) || - left.account.id.localeCompare(right.account.id), - ), - }, - ]), - }; - }, - ); + private encodeCursor(value: LocalOperationsCursor): string { + const body = Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${body}.${createHmac('sha256', this.config.getOrThrow('JWT_SECRET')).update(body).digest('base64url')}`; + } + + private decodeCursor(value: string): LocalOperationsCursor { + try { + const [body, signature, extra] = value.split('.'); + const expected = createHmac( + 'sha256', + this.config.getOrThrow('JWT_SECRET'), + ) + .update(body) + .digest(); + const actual = Buffer.from(signature ?? '', 'base64url'); + if ( + extra || + actual.length !== expected.length || + !timingSafeEqual(actual, expected) + ) + throw new Error(); + const cursor = JSON.parse( + Buffer.from(body, 'base64url').toString(), + ) as Partial; + if ( + (cursor.movingEnd !== undefined && + typeof cursor.movingEnd !== 'boolean') || + !cursor.binding || + !cursor.accountId || + !cursor.id || + !cursor.at || + !cursor.to || + !Number.isFinite(Date.parse(cursor.at)) || + !Number.isFinite(Date.parse(cursor.to)) + ) + throw new Error(); + return cursor as LocalOperationsCursor; + } catch { + throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + } } async collectRanges( userId: string, accountId: string, - ranges: - | DateOnlyRange[] - | ((accountOpenedAt: Date | undefined) => DateOnlyRange[]), - ): Promise<{ - accountOpenedAt: Date | undefined; - pages: BrokerageOperationsPageDto[][]; - }> { - if (accountId === 'all') return this.collectAllRanges(userId, ranges); - this.requestContext.set({ userId, brokerageAccountId: accountId }); - return this.access.withAccount( + ranges: (openedAt: Date | undefined) => DateOnlyRange[], + observe = false, + ) { + const connection = await this.access.local(userId, accountId); + const input = { userId, + revision: connection.revision, accountId, - async ({ token, generation, account }) => { - const accountOpenedAt = account.openedDate?.seconds - ? new Date( - Number(account.openedDate.seconds) * MILLISECONDS_PER_SECOND, - ) - : undefined; - const selected = - typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges; - return { - accountOpenedAt, - pages: await Promise.all( - selected.map((range) => - this.collectRange(token, generation, userId, accountId, range), - ), - ), - }; - }, - ); - } - - private async collectRange( - token: string, - generation: string, - userId: string, - accountId: string, - range: DateOnlyRange, - ): Promise { - const pages: BrokerageOperationsPageDto[] = []; - const seen = new Set(); - let cursor: string | undefined; - do { - const page = await this.page( - token, - generation, - userId, - accountId, - { - from: range.from, - cursor, - limit: 1000, - categories: ['income'], - }, - dateOnlyBounds(range), - ); - pages.push(page); - if (!page.hasNext) return pages; - if (!page.nextCursor || seen.has(page.nextCursor)) - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); - seen.add(page.nextCursor); - cursor = page.nextCursor; - } while (cursor); - return pages; - } - - private async page( - token: string, - generation: string, - userId: string, - accountId: string, - query: BrokerageOperationsQueryDto, - range: { from: Date; to: Date }, - ): Promise { - const key = cacheKey(generation, userId, accountId, query, range); - const cached = this.pages.get(key); - if (cached) return cached; - if (range.from > range.to) { - const page = emptyPage(accountId); - this.pages.set(key, page); - return page; - } - const response = await this.tbank.getOperationsByCursor(token, { - accountId, - from: seconds(range.from), - to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')), - cursor: query.cursor, - limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE, - state: 'OPERATION_STATE_EXECUTED', - ...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}), - }); - const items = (response.items ?? []).filter( - (item) => - (!query.instrumentUid || item.instrumentUid === query.instrumentUid) && - (!query.categories?.length || - query.categories.includes(operationCategory(item.type))), - ); - const logoNames = new Map>(); - const page = { - accountId, - items: await Promise.all( - items.map(async (item) => ({ - ...mapBrokerageOperation(item), - logoName: await this.logoName( - token, - item.instrumentUid, - item.figi, - logoNames, - ), + from: new Date(0), + to: new Date(), + categories: ['income'], + filterKey: `income/${accountId}`, + periods: ranges, + }; + let saved = await this.repository.readPage(input); + const schedule = async () => { + const current = await this.access.local(userId, accountId); + if (current.revision !== connection.revision) return; + const snapshot = await this.repository.readPage(input); + if (!snapshot?.connection.membershipAt) return; + const targets = snapshot.connection.accounts.flatMap((account) => + (snapshot.periods ?? []).map((period) => ({ + accountId: account.accountId, + ...period, })), - ), - nextCursor: response.nextCursor || null, - hasNext: Boolean(response.hasNext), - observedAt: new Date().toISOString(), + ); + if (targets.length) + await this.sync.readPlan(userId, targets, { + movingEnd: snapshot.periods?.some((period) => period.movingEnd), + history: true, + revision: connection.revision, + }); }; - this.pages.set(key, page); - return page; - } - - private range(query: BrokerageOperationsQueryDto) { + if (!observe) { + const discovery = await this.membership.request( + userId, + connection.revision, + ); + if (discovery) void discovery.done.then(schedule).catch(() => undefined); + else await schedule(); + saved = await this.repository.readPage(input); + } const now = new Date(); - return { - from: - dateBoundary(query.from, false) ?? - new Date(Date.UTC(now.getUTCFullYear(), 0, 1)), - to: dateBoundary(query.to, true) ?? now, - }; - } - - private async logoName( - token: string, - instrumentUid: string | undefined, - figi: string | undefined, - logoNames: Map>, - ): Promise { - const uid = instrumentUid?.trim() || null; - const instrumentFigi = figi?.trim() || null; - const key = - uid || instrumentFigi - ? JSON.stringify({ uid, figi: instrumentFigi }) - : null; - if (!key) return Promise.resolve(null); - const existing = logoNames.get(key); - if (existing) return existing; - const logoName = this.metadata - .resolve(token, uid, instrumentFigi) - .then((instrument) => instrument?.brand?.logoName?.trim() || null); - logoNames.set(key, logoName); - return logoName; + const membership = investmentResourceMeta({ + resourceKey: 'accounts/membership', + dataRevision: String(saved?.connection.membershipRevision ?? 0), + availability: saved?.connection.membershipAt ? 'complete' : 'missing', + fetchedAt: saved?.connection.membershipAt ?? null, + freshForMs: tbankSourceTtlMs.membership, + work: saved?.membershipWork ?? null, + }); + // Plan even without a snapshot so cold responses retain the requested identity. + const periods = + saved?.periods ?? + ranges(undefined).map((period) => ({ + ...dateOnlyBounds(period), + key: `${period.from}/${period.to}`, + movingEnd: false, + })); + return periods.map((period, index) => { + const targets = (saved?.connection.accounts ?? []).map((account) => ({ + accountId: account.accountId, + ...period, + to: period.movingEnd ? now : period.to, + })); + const plan = planOperationRead(targets, saved?.coverage ?? [], now, { + movingEnd: true, + }); + const relevant = (saved?.work ?? []).filter((work) => + work.intervals.some((range) => + targets.some((target) => operationIntervalsOverlap(range, target)), + ), + ); + const work = + relevant.find((work) => work.state === 'running') ?? + relevant.find((work) => work.state === 'failed') ?? + saved?.membershipWork ?? + null; + const known = Boolean(saved?.connection.membershipAt); + const complete = + known && + plan.intervals.every((range) => range.availability === 'complete'); + const sources = (saved?.coverage ?? []).filter( + (row) => + !row.instrumentUid && + targets.some((target) => operationIntervalsOverlap(row, target)), + ); + const fetchedAt = + sources + .map((row) => row.fetchedAt) + .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? + (complete ? saved?.connection.membershipAt : null) ?? + null; + const serialize = (range: { + accountId: string; + from: Date; + to: Date; + }) => ({ + accountId: range.accountId, + from: range.from.toISOString(), + to: range.to.toISOString(), + }); + const confirmed = plan.intervals.flatMap((target) => + sources + .filter( + (row) => + row.accountId === target.accountId && + operationIntervalsOverlap(row, target), + ) + .map((row) => ({ + accountId: target.accountId, + from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())), + to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())), + })), + ); + const meta = investmentResourceMeta({ + resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, + dataRevision: saved?.binding ?? 'missing', + availability: !known + ? 'missing' + : complete + ? 'complete' + : sources.length + ? 'partial' + : 'missing', + fetchedAt, + freshForMs: plan.intervals.some((range) => range.stale) ? 0 : Infinity, + work, + dependencies: [membership], + coverage: { + requested: targets.map(serialize), + completeRanges: confirmed.map(serialize), + missingRanges: uncoveredOperationIntervals( + plan.intervals, + confirmed, + ).map(serialize), + asOf: + complete && targets.length + ? new Date( + Math.min( + ...targets.map((target) => + Math.max( + ...plan.intervals + .filter( + (range) => range.accountId === target.accountId, + ) + .map( + (range) => + range.asOf?.valueOf() ?? range.to.valueOf(), + ), + ), + ), + ), + ).toISOString() + : null, + historyBoundary: (saved?.connection.accounts ?? []).every( + (account) => account.openedOn, + ) + ? 'known' + : 'unknown', + historyState: complete + ? 'complete' + : sources.length + ? 'partial' + : 'missing', + }, + }); + return { + meta, + items: complete + ? (saved?.rows ?? []) + .filter((row) => + plan.intervals.some( + (interval) => + interval.accountId === row.accountId && + row.occurredAt >= interval.from && + row.occurredAt < interval.to, + ), + ) + .map((row) => ({ + ...mapBrokerageOperation(row.payload as TbankOperationItem), + ...(accountId === 'all' + ? { + account: { + id: row.accountId, + name: + saved?.connection.accounts.find( + (account) => account.accountId === row.accountId, + )?.name ?? row.accountId, + }, + } + : {}), + })) + : null, + }; + }); } } - -function cacheKey( - generation: string, - userId: string, - accountId: string, - query: BrokerageOperationsQueryDto, - range: { from: Date; to: Date }, -): string { - const categories = [...new Set(query.categories ?? [])].sort(); - const end = query.to - ? range.to.toISOString() - : Math.floor(range.to.valueOf() / freshForMs); - return JSON.stringify({ - generation, - userId, - accountId, - from: range.from.toISOString(), - to: end, - categories, - instrumentUid: query.instrumentUid ?? null, - cursor: query.cursor ?? null, - limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE, - }); -} - -function dateBoundary(value: string | undefined, end: boolean): Date | null { - if (!value) return null; - if (/^\d{4}-\d{2}-\d{2}$/.test(value)) - return new Date(`${value}T${end ? '23:59:59.999' : '00:00:00.000'}Z`); - return new Date(value); -} - -function seconds(value: Date, endOfSecond = false) { - return { - seconds: Math.floor(value.valueOf() / MILLISECONDS_PER_SECOND).toString(), - ...(endOfSecond ? { nanos: 999_999_999 } : {}), - }; -} - -function emptyPage(accountId: string): BrokerageOperationsPageDto { - return { - accountId, - items: [], - nextCursor: null, - hasNext: false, - observedAt: new Date().toISOString(), - }; -} diff --git a/src/modules/investments/operations/domain/operations-sync-plan.spec.ts b/src/modules/investments/operations/domain/operations-sync-plan.spec.ts new file mode 100644 index 0000000..f2c46f0 --- /dev/null +++ b/src/modules/investments/operations/domain/operations-sync-plan.spec.ts @@ -0,0 +1,141 @@ +import { operationIntervals, planOperationRead } from './operations-sync-plan'; + +describe('operations calendar windows', () => { + it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => { + const result = operationIntervals([ + { + accountId: 'a', + from: new Date('2024-02-01T00:00:00+03:00'), + to: new Date('2024-03-01T00:00:00+03:00'), + }, + { + accountId: 'a', + from: new Date('2024-02-10Z'), + to: new Date('2024-04-01Z'), + }, + ]); + expect( + result.map(({ from, to }) => [from.toISOString(), to.toISOString()]), + ).toEqual([ + ['2024-01-31T21:00:00.000Z', '2024-02-01T00:00:00.000Z'], + ['2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'], + ['2024-03-01T00:00:00.000Z', '2024-04-01T00:00:00.000Z'], + ]); + }); + + it('does not combine account or instrument predicates and rejects invalid ranges', () => { + const range = { + from: new Date('2026-12-15Z'), + to: new Date('2027-01-15Z'), + }; + const result = operationIntervals([ + { accountId: 'a', ...range }, + { accountId: 'b', ...range }, + { accountId: 'a', instrumentUid: 'uid', ...range }, + ]); + expect(result).toHaveLength(6); + expect(() => + operationIntervals([{ accountId: 'a', from: range.to, to: range.from }]), + ).toThrow(); + }); +}); + +describe('operation completeness and freshness', () => { + const from = new Date('2026-09-01Z'); + const asOf = new Date('2026-09-25T12:00:00Z'); + const covered = { accountId: 'a', from, to: asOf, asOf, fetchedAt: asOf }; + + it.each([1, 299, 300])( + 'preserves the observed complete end after %s seconds, separately from TTL', + (seconds) => { + const now = new Date(asOf.valueOf() + seconds * 1000); + const plan = planOperationRead( + [{ accountId: 'a', from, to: now }], + [covered], + now, + { movingEnd: true }, + ); + expect(plan.intervals[0]).toMatchObject({ + availability: 'complete', + asOf, + to: asOf, + stale: seconds >= 300, + }); + expect(plan.load).toEqual( + seconds >= 300 ? [{ accountId: 'a', from, to: now }] : [], + ); + }, + ); + + it('preserves a cursor observed end after the clock and calendar advance', () => { + const cursorEnd = new Date(asOf.valueOf() + 1000); + const plan = planOperationRead( + [{ accountId: 'a', from, to: cursorEnd }], + [covered], + new Date('2026-10-01Z'), + { movingEnd: true }, + ); + expect(plan.intervals[0]).toMatchObject({ + availability: 'complete', + to: asOf, + asOf, + }); + expect(plan.load).toEqual([]); + }); + it('does not hide earlier holes or reuse an instrument subset as whole-account completeness', () => { + const now = new Date(asOf.valueOf() + 1000); + const partial = { ...covered, from: new Date('2026-09-10Z') }; + const plan = planOperationRead( + [{ accountId: 'a', from, to: now }], + [partial], + now, + { movingEnd: true }, + ); + expect(plan.intervals[0]).toMatchObject({ + availability: 'partial', + stale: false, + }); + expect(plan.load).toEqual([{ accountId: 'a', from, to: partial.from }]); + expect( + planOperationRead( + [{ accountId: 'a', from, to: now }], + [{ ...covered, instrumentUid: 'uid' }], + now, + ).intervals[0].availability, + ).toBe('missing'); + }); + + it('loads a newly explicit tail and closes the previous month after rollover', () => { + const now = new Date(asOf.valueOf() + 1000); + expect( + planOperationRead([{ accountId: 'a', from, to: now }], [covered], now) + .load, + ).toEqual([{ accountId: 'a', from: asOf, to: now }]); + const monthEnd = new Date('2026-10-01Z'); + const rollover = new Date('2026-10-01T00:00:01Z'); + const plan = planOperationRead( + [{ accountId: 'a', from, to: rollover }], + [covered], + rollover, + { movingEnd: true }, + ); + expect(plan.load).toEqual([ + { accountId: 'a', from: asOf, to: monthEnd }, + { accountId: 'a', from: monthEnd, to: rollover }, + ]); + expect( + planOperationRead( + [{ accountId: 'a', from, to: monthEnd }], + [{ ...covered, to: monthEnd, asOf: monthEnd }], + rollover, + ).load, + ).toEqual([]); + }); + + it('force refresh reloads only the shown target even when coverage is fresh', () => { + const target = { accountId: 'a', from: new Date('2026-09-20Z'), to: asOf }; + expect( + planOperationRead([target], [covered], asOf, { force: true }).load, + ).toEqual([target]); + }); +}); diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts new file mode 100644 index 0000000..555a43c --- /dev/null +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -0,0 +1,183 @@ +export type OperationInterval = { + accountId: string; + from: Date; + to: Date; + instrumentUid?: string; +}; + +// Normalize duplicate/overlapping targets before creating a durable identity. +// Calendar windows keep each provider walk bounded without changing [from,to). +export function operationIntervals( + requested: OperationInterval[], +): OperationInterval[] { + const groups = new Map(); + for (const interval of requested) { + if (!(interval.from < interval.to)) + throw new Error('Invalid operations interval'); + const key = JSON.stringify([ + interval.accountId, + interval.instrumentUid ?? '', + ]); + const group = groups.get(key) ?? []; + group.push(interval); + groups.set(key, group); + } + const result: OperationInterval[] = []; + for (const key of [...groups.keys()].sort()) { + const merged: OperationInterval[] = []; + for (const interval of (groups.get(key) ?? []).sort( + (left, right) => left.from.valueOf() - right.from.valueOf(), + )) { + const previous = merged.at(-1); + if (previous && interval.from <= previous.to) + previous.to = new Date( + Math.max(previous.to.valueOf(), interval.to.valueOf()), + ); + else merged.push({ ...interval }); + } + for (const interval of merged) { + let from = interval.from; + while (from < interval.to) { + const nextMonth = new Date( + Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + 1, 1), + ); + const to = new Date( + Math.min(nextMonth.valueOf(), interval.to.valueOf()), + ); + result.push({ ...interval, from, to }); + from = to; + } + } + } + return result; +} + +export function operationIntervalsOverlap( + left: OperationInterval, + right: OperationInterval, +): boolean { + return ( + left.accountId === right.accountId && + (!left.instrumentUid || + !right.instrumentUid || + left.instrumentUid === right.instrumentUid) && + left.from < right.to && + right.from < left.to + ); +} + +export function uncoveredOperationIntervals( + requested: OperationInterval[], + existing: OperationInterval[], +): OperationInterval[] { + return requested.flatMap((interval) => { + let fragments = [interval]; + for (const covered of existing) { + fragments = fragments.flatMap((fragment) => { + if ( + !operationIntervalsOverlap(fragment, covered) || + (covered.instrumentUid && + covered.instrumentUid !== fragment.instrumentUid) + ) + return [fragment]; + return [ + ...(fragment.from < covered.from + ? [{ ...fragment, to: covered.from }] + : []), + ...(covered.to < fragment.to + ? [{ ...fragment, from: covered.to }] + : []), + ]; + }); + } + return fragments; + }); +} + +export type OperationCoverage = OperationInterval & { + fetchedAt: Date; + asOf: Date; +}; +const operationsFreshForMs = 300_000; + +export function planOperationRead( + requested: OperationInterval[], + coverage: OperationCoverage[], + now: Date, + options: { movingEnd?: boolean; force?: boolean } = {}, +) { + const monthStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), + ); + const observedEnd = Math.max( + ...requested.map((interval) => interval.to.valueOf()), + ); + const load: OperationInterval[] = []; + const intervals = operationIntervals(requested).map((target) => { + const sources = coverage.filter( + (row) => + row.accountId === target.accountId && + (!row.instrumentUid || row.instrumentUid === target.instrumentUid) && + row.from < target.to && + target.from < row.to, + ); + const ranges = sources.map((row) => ({ + ...row, + instrumentUid: target.instrumentUid, + })); + const current = target.from >= monthStart && target.to <= now; + const fresh = sources.filter( + (row) => now.valueOf() - row.fetchedAt.valueOf() < operationsFreshForMs, + ); + let effective = target; + if (options.movingEnd && target.to.valueOf() === observedEnd) { + const latest = sources + .filter( + (row) => + row.asOf.valueOf() === row.to.valueOf() && row.to > target.from, + ) + .sort((left, right) => right.to.valueOf() - left.to.valueOf())[0]; + if (latest && latest.to < target.to) + effective = { ...target, to: latest.to }; + } + const missing = uncoveredOperationIntervals([effective], ranges); + const complete = missing.length === 0; + const stale = + current && + sources.length > 0 && + uncoveredOperationIntervals( + ranges + .map((row) => ({ + ...target, + from: new Date( + Math.max(effective.from.valueOf(), row.from.valueOf()), + ), + to: new Date(Math.min(effective.to.valueOf(), row.to.valueOf())), + })) + .filter((row) => row.from < row.to), + fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })), + ).length > 0; + if (options.force) load.push(target); + else if (stale) load.push({ ...target, from: monthStart, to: now }); + else load.push(...missing); + // Completeness is bounded by the observed end, never by the new wall clock. + return { + ...effective, + availability: complete + ? ('complete' as const) + : sources.length + ? ('partial' as const) + : ('missing' as const), + stale, + asOf: sources.length + ? new Date( + Math.min( + effective.to.valueOf(), + Math.max(...sources.map((row) => row.asOf.valueOf())), + ), + ) + : null, + }; + }); + return { intervals, load: operationIntervals(load) }; +} diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts new file mode 100644 index 0000000..e613cf2 --- /dev/null +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -0,0 +1,922 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { createHash, randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; +import { PrismaService } from '../../../../prisma/prisma.service'; +import type { Prisma } from '../../../../../generated/prisma/client'; +import type { + TbankOperationItem, + TbankOperationsByCursorResponse, +} from '../../../../integrations/tbank/tbank.api'; +import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar'; +import { operationCategory } from './brokerage-operation.mapper'; +import { + type OperationInterval, + operationIntervals, + operationIntervalsOverlap, + uncoveredOperationIntervals, +} from '../domain/operations-sync-plan'; +export type { OperationInterval } from '../domain/operations-sync-plan'; + +export type OperationAttempt = { + id: string; + attemptId: string; + userId: string; + revision: string; +}; +type OperationsIntent = { + intervals: string[]; + atomic: boolean; + atomicIntervals?: string[]; + screenIntervals?: string[]; +}; +type PageProgress = { + complete: boolean; + cursor: string; + seen: string[]; + pages: number; +}; +const progressItem = ''; +const NANOSECONDS_PER_MILLISECOND = 1_000_000; + +@Injectable() +export class BrokerageOperationsRepository { + constructor(private readonly prisma: PrismaService) {} + + async readPage(input: { + userId: string; + revision: string; + accountId: string; + from: Date; + to: Date; + instrumentUid?: string; + categories: string[]; + limit?: number; + periods?: (openedAt: Date | undefined) => DateOnlyRange[]; + filterKey: string; + cursor?: { binding: string; at: string; accountId: string; id: string }; + }) { + return this.prisma.$transaction(async (tx) => { + const connection = await tx.tbankConnection.findUnique({ + where: { + userId_revision: { userId: input.userId, revision: input.revision }, + invalidToken: false, + }, + select: { + membershipAt: true, + membershipRevision: true, + accounts: { + where: + input.accountId === 'all' ? {} : { accountId: input.accountId }, + orderBy: { accountId: 'asc' }, + select: { + accountId: true, + name: true, + openedOn: true, + operationsRevision: true, + }, + }, + }, + }); + if (!connection) return null; + if ( + connection.membershipAt && + input.accountId !== 'all' && + !connection.accounts.length + ) + throw new NotFoundException(); + const accounts = connection.accounts; + const oldest = accounts.map((account) => account.openedOn).sort()[0]; + const openedAt = + oldest && accounts.every((account) => account.openedOn) + ? new Date(`${oldest}T00:00:00+03:00`) + : undefined; + const periods = input.periods?.(openedAt).map((period) => { + const bounds = dateOnlyBounds(period); + // Date-only end is inclusive; persisted coverage is half-open. + const end = new Date(`${period.to}T21:00:00Z`); + return { + ...bounds, + key: `${period.from}/${period.to}`, + movingEnd: end.valueOf() > Date.now(), + to: new Date(Math.min(end.valueOf(), Date.now())), + }; + }); + const binding = createHash('sha256') + .update( + JSON.stringify([ + input.userId, + input.revision, + input.accountId === 'all' ? connection.membershipRevision : null, + accounts.map((account) => [ + account.accountId, + account.operationsRevision, + ]), + input.filterKey, + ]), + ) + .digest('base64url'); + if (input.cursor && input.cursor.binding !== binding) + throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + const scope = { + userId: input.userId, + revision: input.revision, + accountId: { in: accounts.map((account) => account.accountId) }, + }; + const coverage = await tx.tbankOperationCoverage.findMany({ + where: scope, + }); + const work = await tx.tbankSyncWork.findMany({ + where: { + userId: input.userId, + revision: input.revision, + scopeKey: { startsWith: 'operations:' }, + state: { in: ['running', 'failed'] }, + }, + select: { + id: true, + state: true, + errorCode: true, + intent: true, + priority: true, + }, + }); + const membershipWork = await tx.tbankSyncWork.findUnique({ + where: { + userId_revision_scopeKey: { + userId: input.userId, + revision: input.revision, + scopeKey: 'membership', + }, + }, + select: { id: true, state: true, errorCode: true }, + }); + const after = input.cursor; + const rows = await tx.tbankOperation.findMany({ + where: { + ...scope, + ...(periods + ? { + OR: periods.map((period) => ({ + occurredAt: { gte: period.from, lt: period.to }, + })), + } + : { occurredAt: { gte: input.from, lt: input.to } }), + ...(input.instrumentUid + ? { instrumentUid: input.instrumentUid } + : {}), + ...(input.categories.length + ? { category: { in: input.categories } } + : {}), + ...(after + ? { + OR: [ + { occurredAt: { lt: new Date(after.at) } }, + { + occurredAt: new Date(after.at), + accountId: { gt: after.accountId }, + }, + { + occurredAt: new Date(after.at), + accountId: after.accountId, + brokerOperationId: { gt: after.id }, + }, + ], + } + : {}), + }, + orderBy: [ + { occurredAt: 'desc' }, + { accountId: 'asc' }, + { brokerOperationId: 'asc' }, + ], + ...(input.limit === undefined ? {} : { take: input.limit + 1 }), + }); + return { + connection, + periods, + membershipWork, + binding, + rows, + coverage: coverage.map(({ instrumentPredicate, ...row }) => ({ + ...row, + instrumentUid: instrumentPredicate || undefined, + })), + work: work.map(({ intent, ...row }) => ({ + ...row, + intervals: (intent as { intervals: string[] }).intervals.map( + decodeInterval, + ), + })), + }; + }); + } + + async coverage(userId: string, revision: string, accountIds: string[]) { + const rows = await this.prisma.tbankOperationCoverage.findMany({ + where: { + userId, + revision, + accountId: { in: accountIds }, + account: { connection: { invalidToken: false } }, + }, + }); + return rows.map(({ instrumentPredicate, ...row }) => ({ + ...row, + instrumentUid: instrumentPredicate || undefined, + })); + } + + async request( + userId: string, + revision: string, + intervals: OperationInterval[], + processRunId: string, + atomic: boolean, + runningAttempts: ReadonlySet, + priority: number, + ) { + if (!intervals.length) throw new Error('Empty operations request'); + const scopeKey = + 'operations:' + + createHash('sha256') + .update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort())) + .digest('hex'); + return this.prisma.$transaction(async (tx) => { + const locked = await tx.tbankConnection.updateMany({ + where: { + userId, + revision, + invalidToken: false, + membershipAt: { not: null }, + }, + data: { membershipRevision: { increment: 0 } }, + }); + if (!locked.count) return null; + const accounts = await tx.tbankAccount.findMany({ + where: { userId, revision }, + select: { accountId: true, openedOn: true }, + }); + if ( + intervals.some( + (interval) => + !accounts.some( + (account) => account.accountId === interval.accountId, + ), + ) + ) + throw new NotFoundException(); + if (priority > 1) { + for (const account of accounts.filter((account) => + intervals.some( + (interval) => interval.accountId === account.accountId, + ), + )) + await tx.tbankAccount.update({ + where: { + userId_revision_accountId: { + userId, + revision, + accountId: account.accountId, + }, + }, + data: { + historyFrom: new Date( + account.openedOn + ? `${account.openedOn}T00:00:00Z` + : '1970-01-01T00:00:00Z', + ), + historyBoundary: account.openedOn ? 'known' : 'unknown', + }, + }); + } + const coverage = await tx.tbankOperationCoverage.findMany({ + where: { + userId, + revision, + accountId: { in: intervals.map((interval) => interval.accountId) }, + }, + }); + const publicationAtomic = + atomic || + intervals.every( + (interval) => + uncoveredOperationIntervals( + [interval], + coverage + .filter( + (row) => + row.accountId === interval.accountId && + (!row.instrumentPredicate || + row.instrumentPredicate === interval.instrumentUid), + ) + .map((row) => ({ + accountId: row.accountId, + from: row.from, + to: row.to, + instrumentUid: interval.instrumentUid, + })), + ).length === 0, + ); + const active = await tx.tbankSyncWork.findMany({ + where: { + userId, + revision, + state: 'running', + scopeKey: { startsWith: 'operations:' }, + }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }); + const touching = active.filter((work) => { + const pending = (work.intent as { intervals: string[] }).intervals.map( + decodeInterval, + ); + return pending.some((existing) => + intervals.some((incoming) => + operationIntervalsOverlap(existing, incoming), + ), + ); + }); + const previous = + touching[0] ?? + (await tx.tbankSyncWork.findUnique({ + where: { userId_revision_scopeKey: { userId, revision, scopeKey } }, + })); + const identity = { + userId, + revision, + scopeKey: previous?.scopeKey ?? scopeKey, + }; + const previousIntent = previous?.intent as OperationsIntent | undefined; + const upgradeGroup = + publicationAtomic && previousIntent && !previousIntent.atomic; + if ( + touching.length <= 1 && + previous?.state === 'running' && + previous.processRunId === processRunId && + runningAttempts.has(previous.attemptId) && + !upgradeGroup + ) { + const pending = previousIntent?.intervals.map(decodeInterval) ?? []; + const additions = + previous.scopeKey === scopeKey + ? [] + : uncoveredOperationIntervals(intervals, pending); + const work = await tx.tbankSyncWork.update({ + where: { id: previous.id }, + data: { + intent: { + atomic: previousIntent?.atomic ?? publicationAtomic, + atomicIntervals: previousIntent?.atomic + ? [ + ...new Set([ + ...(previousIntent.atomicIntervals ?? + previousIntent.intervals), + ...additions.map(intervalKey), + ]), + ] + : [], + screenIntervals: [ + ...new Set([ + ...(previousIntent?.screenIntervals ?? + (previous.priority === 1 + ? (previousIntent?.intervals ?? []) + : [])), + ...(priority === 1 + ? [...pending, ...additions] + .filter((item) => + intervals.some((target) => + operationIntervalsOverlap(item, target), + ), + ) + .map(intervalKey) + : []), + ]), + ], + intervals: (priority < previous.priority + ? [ + ...pending.filter((item) => + intervals.some((target) => + operationIntervalsOverlap(item, target), + ), + ), + ...additions, + ...pending.filter( + (item) => + !intervals.some((target) => + operationIntervalsOverlap(item, target), + ), + ), + ] + : [...pending, ...additions] + ).map(intervalKey), + }, + priority: Math.min(priority, previous.priority), + }, + }); + return { work, launch: false }; + } + const recovering = + previous && + previous.state !== 'complete' && + previous.state !== 'superseded'; + const retained = recovering + ? (previousIntent?.intervals.map(decodeInterval) ?? []) + : []; + // A bridge between two active groups must fence both producers before + // either can publish a subset of the newly combined result. + for (const joined of touching) { + if (joined.id === previous?.id) continue; + retained.push( + ...(joined.intent as { intervals: string[] }).intervals.map( + decodeInterval, + ), + ); + await tx.tbankSyncWork.update({ + where: { id: joined.id }, + data: { + state: 'superseded', + progress: { successorId: previous?.id ?? '' }, + }, + }); + await tx.tbankSyncStage.deleteMany({ where: { workId: joined.id } }); + } + if (previous) + await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); + const targets = + recovering && !upgradeGroup && touching.length <= 1 + ? previous?.scopeKey === scopeKey + ? retained + : [...retained, ...uncoveredOperationIntervals(intervals, retained)] + : operationIntervals([...retained, ...intervals]); + const atomicTargets = [ + ...(publicationAtomic ? intervals : []), + ...(recovering && previousIntent?.atomic + ? (previousIntent.atomicIntervals ?? previousIntent.intervals).map( + decodeInterval, + ) + : []), + ...touching.flatMap((work) => { + const joined = work.intent as OperationsIntent; + return joined.atomic + ? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) + : []; + }), + ]; + const intent = { + intervals: targets.map(intervalKey), + atomicIntervals: targets + .filter((target) => + atomicTargets.some((atomic) => + operationIntervalsOverlap(target, atomic), + ), + ) + .map(intervalKey), + screenIntervals: targets + .filter( + (target) => + (priority === 1 && + intervals.some((incoming) => + operationIntervalsOverlap(target, incoming), + )) || + ( + previousIntent?.screenIntervals ?? + (previous?.priority === 1 + ? (previousIntent?.intervals ?? []) + : []) + ).some((key) => + operationIntervalsOverlap(target, decodeInterval(key)), + ), + ) + .map(intervalKey), + atomic: atomicTargets.length > 0, + }; + const data = { + intent, + state: 'running', + attemptId: randomUUID(), + processRunId, + priority: Math.min( + priority, + previous?.priority ?? priority, + ...touching.map((work) => work.priority), + ), + progress: {}, + errorCode: null, + }; + const work = await tx.tbankSyncWork.upsert({ + where: { userId_revision_scopeKey: identity }, + create: { ...identity, ...data, id: randomUUID() }, + update: data, + }); + return { work, launch: true }; + }); + } + + async pending(attempt: OperationAttempt) { + const work = await this.prisma.tbankSyncWork.findFirst({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + select: { intent: true, priority: true }, + }); + if (!work) return null; + const intent = work.intent as OperationsIntent; + const markers = await this.prisma.tbankSyncStage.findMany({ + where: { + workId: attempt.id, + attemptId: attempt.attemptId, + itemKey: progressItem, + }, + select: { sourceKey: true, payload: true }, + }); + const progress = new Map( + markers.map((marker) => [ + marker.sourceKey, + marker.payload as PageProgress, + ]), + ); + const group = intent.atomic + ? (intent.atomicIntervals ?? intent.intervals) + : intent.intervals; + const nextKey = group.find((key) => !progress.get(key)?.complete); + return { + priority: work.priority, + atomic: intent.atomic, + intervals: group.map(decodeInterval), + next: nextKey + ? { + interval: decodeInterval(nextKey), + cursor: progress.get(nextKey)?.cursor || undefined, + } + : null, + }; + } + + async observe(userId: string, revision: string, id: string) { + let target = id; + const seen = new Set(); + while (!seen.has(target)) { + seen.add(target); + const work = await this.prisma.tbankSyncWork.findFirst({ + where: { + id: target, + userId, + revision, + scopeKey: { startsWith: 'operations:' }, + }, + select: { + id: true, + state: true, + progress: true, + errorCode: true, + updatedAt: true, + }, + }); + if (!work || work.state !== 'superseded') + return work ? { ...work, id } : null; + target = (work.progress as { successorId: string }).successorId; + } + throw new Error('Cyclic operations work successor'); + } + + async fail(attempt: OperationAttempt, invalidToken: boolean) { + return this.prisma.$transaction(async (tx) => { + if (!(await claim(tx, attempt))) return false; + await tx.tbankSyncWork.update({ + where: { id: attempt.id }, + data: { + state: 'failed', + errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE', + }, + }); + if (invalidToken) + await tx.tbankConnection.update({ + where: { + userId_revision: { + userId: attempt.userId, + revision: attempt.revision, + }, + }, + data: { invalidToken: true }, + }); + return true; + }); + } + + async stagePage( + attempt: OperationAttempt, + interval: OperationInterval, + cursor: string | undefined, + response: TbankOperationsByCursorResponse, + ): Promise { + const sourceKey = intervalKey(interval); + return this.prisma.$transaction(async (tx) => { + if (!(await claim(tx, attempt))) return false; + const stage = { + workId: attempt.id, + attemptId: attempt.attemptId, + sourceKey, + }; + const marker = await tx.tbankSyncStage.findUnique({ + where: { + workId_attemptId_sourceKey_itemKey: { + ...stage, + itemKey: progressItem, + }, + }, + }); + const progress = marker?.payload as PageProgress | undefined; + if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? '')) + throw new Error('Unexpected operations page'); + if (response.hasNext === undefined) + throw new Error('Missing operations pagination state'); + const nextCursor = response.nextCursor ?? ''; + if ( + response.hasNext && + (!nextCursor || + nextCursor === cursor || + progress?.seen.includes(nextCursor)) + ) + throw new Error('Invalid operations continuation'); + for (const operation of response.items ?? []) { + if (!operation.id?.trim()) + throw new Error('Missing broker operation identity'); + const occurredAt = new Date( + Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND + + Math.floor( + (operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND, + ), + ); + if (!Number.isFinite(occurredAt.valueOf())) + throw new Error('Invalid operation timestamp'); + if ( + occurredAt < interval.from || + occurredAt >= interval.to || + (interval.instrumentUid && + operation.instrumentUid !== interval.instrumentUid) + ) + continue; + const payload = JSON.parse( + JSON.stringify({ ...operation, cursor: undefined }), + ) as Prisma.InputJsonObject; + // Provider pagination position is not part of a financial row. + const identity = { ...stage, itemKey: operation.id }; + const previous = await tx.tbankSyncStage.findUnique({ + where: { workId_attemptId_sourceKey_itemKey: identity }, + }); + if (previous && !isDeepStrictEqual(previous.payload, payload)) + throw new Error('Conflicting broker operation identity'); + if (!previous) + await tx.tbankSyncStage.create({ data: { ...identity, payload } }); + } + const payload: PageProgress = { + complete: !response.hasNext, + cursor: nextCursor, + seen: [ + ...(progress?.seen ?? []), + ...(response.hasNext ? [nextCursor] : []), + ], + pages: (progress?.pages ?? 0) + 1, + }; + await tx.tbankSyncStage.upsert({ + where: { + workId_attemptId_sourceKey_itemKey: { + ...stage, + itemKey: progressItem, + }, + }, + create: { ...stage, itemKey: progressItem, payload }, + update: { payload }, + }); + await tx.tbankSyncWork.update({ + where: { id: attempt.id }, + data: { progress: { sourceKey, ...payload } }, + }); + return true; + }); + } + + // A complete shown scope passes every account/month/comparison here together. + // Initial missing history may pass one completed interval at a time. + async publish( + attempt: OperationAttempt, + intervals: OperationInterval[], + fetchedAt: Date, + advanceWork = false, + ): Promise { + if (!intervals.length) + throw new Error('Empty operations publication group'); + return this.prisma.$transaction( + async (tx) => { + if (!(await claim(tx, attempt))) return false; + const changedAccounts = new Set(); + const groupRows = new Map(); + for (const interval of intervals) { + const sourceKey = intervalKey(interval); + const stage = { + workId: attempt.id, + attemptId: attempt.attemptId, + sourceKey, + }; + const marker = await tx.tbankSyncStage.findUnique({ + where: { + workId_attemptId_sourceKey_itemKey: { + ...stage, + itemKey: progressItem, + }, + }, + }); + if (!(marker?.payload as PageProgress | undefined)?.complete) + throw new Error('Incomplete operations publication group'); + const staged = await tx.tbankSyncStage.findMany({ + where: { ...stage, itemKey: { not: progressItem } }, + }); + for (const row of staged) { + const identity = JSON.stringify([interval.accountId, row.itemKey]); + const previous = groupRows.get(identity); + if ( + previous !== undefined && + !isDeepStrictEqual(previous, row.payload) + ) + throw new Error( + 'Conflicting broker operation identity across intervals', + ); + groupRows.set(identity, row.payload); + } + const rows = staged.filter((row) => { + const state = (row.payload as TbankOperationItem).state; + return !state || state === 'OPERATION_STATE_EXECUTED'; + }); + const scope = { + userId: attempt.userId, + revision: attempt.revision, + accountId: interval.accountId, + }; + const existing = await tx.tbankOperation.findMany({ + where: { + ...scope, + occurredAt: { gte: interval.from, lt: interval.to }, + ...(interval.instrumentUid + ? { instrumentUid: interval.instrumentUid } + : {}), + }, + }); + const incoming = new Set(rows.map((row) => row.itemKey)); + const removed = existing.filter( + (row) => !incoming.has(row.brokerOperationId), + ); + for (const row of removed) + await tx.tbankOperation.delete({ + where: { + userId_revision_accountId_brokerOperationId: { + ...scope, + brokerOperationId: row.brokerOperationId, + }, + }, + }); + if (removed.length) changedAccounts.add(interval.accountId); + for (const row of rows) { + const operation = row.payload as TbankOperationItem; + const identity = { ...scope, brokerOperationId: row.itemKey }; + const previous = await tx.tbankOperation.findUnique({ + where: { userId_revision_accountId_brokerOperationId: identity }, + }); + if (previous && isDeepStrictEqual(previous.payload, operation)) + continue; + const data = { + occurredAt: new Date( + Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND + + Math.floor( + (operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND, + ), + ), + instrumentUid: operation.instrumentUid ?? null, + category: operationCategory(operation.type), + payload: row.payload as Prisma.InputJsonObject, + }; + await tx.tbankOperation.upsert({ + where: { userId_revision_accountId_brokerOperationId: identity }, + create: { ...identity, ...data }, + update: data, + }); + changedAccounts.add(interval.accountId); + } + const coverage = { + ...scope, + instrumentPredicate: interval.instrumentUid ?? '', + from: interval.from, + to: interval.to, + }; + await tx.tbankOperationCoverage.upsert({ + where: { + userId_revision_accountId_instrumentPredicate_from_to: coverage, + }, + create: { ...coverage, fetchedAt, asOf: interval.to }, + update: { fetchedAt, asOf: interval.to }, + }); + } + for (const accountId of changedAccounts) + await tx.tbankAccount.update({ + where: { + userId_revision_accountId: { + userId: attempt.userId, + revision: attempt.revision, + accountId, + }, + }, + data: { operationsRevision: { increment: 1 } }, + }); + for (const interval of intervals) + await tx.tbankSyncStage.deleteMany({ + where: { + workId: attempt.id, + attemptId: attempt.attemptId, + sourceKey: intervalKey(interval), + }, + }); + if (advanceWork) { + const work = await tx.tbankSyncWork.findUniqueOrThrow({ + where: { id: attempt.id }, + }); + const intent = work.intent as OperationsIntent; + const published = new Set(intervals.map(intervalKey)); + const remaining = intent.intervals.filter( + (key) => !published.has(key), + ); + await tx.tbankSyncWork.update({ + where: { id: attempt.id }, + data: { + intent: { + ...intent, + intervals: remaining, + atomic: + intent.atomic && + (intent.atomicIntervals ?? intent.intervals).some( + (key) => !published.has(key), + ), + atomicIntervals: ( + intent.atomicIntervals ?? intent.intervals + ).filter((key) => !published.has(key)), + }, + state: remaining.length ? 'running' : 'complete', + priority: remaining.some((key) => + ( + intent.screenIntervals ?? + (work.priority === 1 ? intent.intervals : []) + ).includes(key), + ) + ? 1 + : 2, + progress: { remainingIntervals: remaining.length }, + }, + }); + } + return true; + }, + { timeout: 30_000 }, + ); + } +} + +function intervalKey(interval: OperationInterval): string { + if (!(interval.from < interval.to)) + throw new Error('Invalid operations interval'); + return JSON.stringify([ + interval.accountId, + interval.from.toISOString(), + interval.to.toISOString(), + interval.instrumentUid ?? '', + ]); +} + +async function claim( + tx: Prisma.TransactionClient, + attempt: OperationAttempt, +): Promise { + const result = await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { updatedAt: new Date() }, + }); + return result.count === 1; +} + +function decodeInterval(key: string): OperationInterval { + const [accountId, from, to, instrumentUid] = JSON.parse(key) as string[]; + return { + accountId, + from: new Date(from), + to: new Date(to), + ...(instrumentUid ? { instrumentUid } : {}), + }; +} diff --git a/src/modules/investments/operations/operations.api.ts b/src/modules/investments/operations/operations.api.ts index 884739e..6706b81 100644 --- a/src/modules/investments/operations/operations.api.ts +++ b/src/modules/investments/operations/operations.api.ts @@ -1,3 +1,11 @@ // Public cross-module surface. Nest providers are exported by the matching module. export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; export { BrokerageOperationsService } from './application/brokerage-operations.service'; + +export { + operationIntervals, + type OperationInterval, +} from './domain/operations-sync-plan'; + +export { operationCategories } from './presentation/brokerage-operation.dto'; +export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; diff --git a/src/modules/investments/operations/operations.module.ts b/src/modules/investments/operations/operations.module.ts index 4704655..16f37dc 100644 --- a/src/modules/investments/operations/operations.module.ts +++ b/src/modules/investments/operations/operations.module.ts @@ -3,11 +3,17 @@ import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; import { BrokerageOperationsService } from './application/brokerage-operations.service'; +import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository'; +import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; @Module({ imports: [AuthModule, TbankModule], controllers: [BrokerageOperationsController], - providers: [BrokerageOperationsService], - exports: [BrokerageOperationsService], + providers: [ + BrokerageOperationsService, + BrokerageOperationsRepository, + BrokerageOperationsSyncService, + ], + exports: [BrokerageOperationsService, BrokerageOperationsSyncService], }) export class OperationsModule {} diff --git a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts index c9756a2..6846422 100644 --- a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts +++ b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts @@ -1,3 +1,7 @@ +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { @@ -30,7 +34,7 @@ export const operationCategories = [ ] as const; export type OperationCategory = (typeof operationCategories)[number]; -export class BrokerageOperationsQueryDto { +export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto { @ApiPropertyOptional({ format: 'uuid', description: @@ -144,3 +148,10 @@ export class BrokerageOperationsPageDto { @ApiProperty({ format: 'date-time' }) declare observedAt: string; } + +export class BrokerageOperationsResourceDto { + @ApiProperty({ type: BrokerageOperationsPageDto, nullable: true }) + declare data: BrokerageOperationsPageDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} diff --git a/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts b/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts index 84c7984..66196a5 100644 --- a/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts +++ b/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts @@ -6,13 +6,13 @@ import { BrokerageOperationsController } from './brokerage-operations.controller describe('BrokerageOperationsController', () => { it('passes the authenticated user, account, and validated query to the service', async () => { - const operations = { get: jest.fn() }; + const operations = { read: jest.fn() }; const controller = new BrokerageOperationsController(operations as never); const query = { limit: 10 }; const request = { user: { sub: 'user' } }; await controller.get(request as never, 'account', query); - expect(operations.get).toHaveBeenCalledWith('user', 'account', query); + expect(operations.read).toHaveBeenCalledWith('user', 'account', query); }); }); diff --git a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts index 3f81ceb..317e4ec 100644 --- a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts +++ b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts @@ -4,7 +4,7 @@ import type { Request } from 'express'; import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { - BrokerageOperationsPageDto, + BrokerageOperationsResourceDto, BrokerageOperationsQueryDto, } from './brokerage-operation.dto'; import { BrokerageOperationsService } from '../application/brokerage-operations.service'; @@ -18,21 +18,21 @@ export class BrokerageOperationsController { constructor(private readonly operations: BrokerageOperationsService) {} @Get('all/operations') - @ApiOkResponse({ type: BrokerageOperationsPageDto }) + @ApiOkResponse({ type: BrokerageOperationsResourceDto }) all( @Req() request: Request & { user: AccessPayload }, @Query() query: BrokerageOperationsQueryDto, ) { - return this.operations.getAll(request.user.sub, query); + return this.operations.read(request.user.sub, 'all', query); } @Get(':accountId/operations') - @ApiOkResponse({ type: BrokerageOperationsPageDto }) + @ApiOkResponse({ type: BrokerageOperationsResourceDto }) get( @Req() request: Request & { user: AccessPayload }, @Param('accountId') accountId: string, @Query() query: BrokerageOperationsQueryDto, ) { - return this.operations.get(request.user.sub, accountId, query); + return this.operations.read(request.user.sub, accountId, query); } } diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts new file mode 100644 index 0000000..07b1686 --- /dev/null +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; +import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; +import { + ForecastRepository, + type ForecastSourceVersion, +} from '../infrastructure/forecast.repository'; + +@Injectable() +export class ForecastService { + constructor(private readonly repository: ForecastRepository) {} + + async result(input: { + userId: string; + revision: string; + membershipRevision: number; + scopeKey: string; + portfolio: InvestmentResourceMetaDto; + sources: { + version: ForecastSourceVersion | null; + meta: InvestmentResourceMetaDto; + }[]; + candidate?: T; + }) { + const dependencies = [ + input.portfolio, + ...input.sources.map((source) => source.meta), + ]; + const stored = await this.repository.resolve({ + ...input, + portfolioProjection: input.portfolio.dataRevision, + portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) => + dependency.resourceKey.startsWith('portfolio/') + ? [ + { + accountId: decodeURIComponent( + dependency.resourceKey.slice('portfolio/'.length), + ), + dataRevision: Number( + (dependency as InvestmentResourceMetaDto).dataRevision, + ), + }, + ] + : [], + ), + sources: input.sources.flatMap((source) => + source.version ? [source.version] : [], + ), + timestamps: dependencies.flatMap((dependency) => + dependency.lastSuccessfulAt + ? [ + { + resourceKey: dependency.resourceKey, + at: dependency.lastSuccessfulAt, + }, + ] + : [], + ), + }); + const timestamps = stored?.sourceTimestamps as + { resourceKey: string; at: string }[] | undefined; + const first = timestamps?.map((entry) => entry.at).sort()[0]; + const work = + dependencies + .map((dependency) => dependency.sync) + .find((sync) => sync.state === 'running') ?? + dependencies + .map((dependency) => dependency.sync) + .find((sync) => sync.state === 'failed'); + return { + data: stored ? (stored.payload as T) : null, + meta: investmentResourceMeta({ + resourceKey: input.scopeKey, + dataRevision: String(stored?.dataRevision ?? 0), + availability: stored + ? 'complete' + : dependencies.some( + (dependency) => dependency.availability !== 'missing', + ) + ? 'partial' + : 'missing', + fetchedAt: first ? new Date(first) : null, + freshForMs: Number.POSITIVE_INFINITY, + work: work?.id ? { ...work, id: work.id } : null, + dependencies, + }), + }; + } +} diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index bbd5d7e..5c3603b 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -1,373 +1,197 @@ -import { - BadRequestException, - ConflictException, - ServiceUnavailableException, -} from '@nestjs/common'; -import assert from 'node:assert/strict'; - -jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({ - TbankAccessService: class TbankAccessService {}, +import { setImmediate as immediate } from 'node:timers/promises'; +import { ServiceUnavailableException } from '@nestjs/common'; +jest.mock('../../../../prisma/prisma.service', () => ({ + PrismaService: class {}, })); - -import { InstrumentMetadataService } from '../../../../integrations/tbank/instrument-metadata.service'; -import type { - TbankAccountContext, - TbankConnectionContext, -} from '../../../../integrations/tbank/tbank-access.service'; import { PortfolioService } from './portfolio.service'; +import { PortfolioDto } from '../presentation/portfolio.dto'; -describe('PortfolioService', () => { - const access = { - withConnection: jest.fn(), - withAccountForConnection: jest.fn(), +describe('persistent portfolio projection', () => { + const access = { local: jest.fn(), tokenForAttempt: jest.fn() }; + const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() }; + const metadata = { savedMany: jest.fn(), request: jest.fn() }; + const repository = { + read: jest.fn(), + reserve: jest.fn(), + publish: jest.fn(), + fail: jest.fn(), }; - const tbank = { - getPortfolio: jest.fn(), - getPositions: jest.fn(), - getInstrumentByFigi: jest.fn(), - getInstrumentByUid: jest.fn(), - }; - const requestContext = { set: jest.fn() }; - const logger = { event: jest.fn() }; - const metadata = new InstrumentMetadataService( - tbank as never, - logger as never, - ); - const service = new PortfolioService( - access as never, - tbank as never, - requestContext as never, - metadata, - ); - let generation: string; - + let service: PortfolioService; + const membershipAt = new Date(); beforeEach(() => { - jest.clearAllMocks(); - service.invalidate('user'); - generation = 'generation-1'; - access.withConnection.mockImplementation( - ( - _userId: string, - handler: (context: TbankConnectionContext) => Promise, - ) => handler({ token: 'token', generation }), + jest.resetAllMocks(); + service = new PortfolioService( + access as never, + tbank as never, + { set: jest.fn() } as never, + metadata as never, + repository as never, + { request: jest.fn().mockResolvedValue(null) } as never, + { event: jest.fn() } as never, + { prefetch: jest.fn().mockResolvedValue(undefined) } as never, ); - access.withAccountForConnection.mockImplementation( - ( - connection: TbankConnectionContext, - _accountId: string, - handler: (context: TbankAccountContext) => Promise, - ) => - handler({ - ...connection, - account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' }, - }), - ); - tbank.getPortfolio.mockResolvedValue({ positions: [] }); - tbank.getPositions.mockResolvedValue({ money: [] }); + access.local.mockResolvedValue({ + revision: 'private', + membershipAt, + accounts: [{ accountId: 'a' }, { accountId: 'b' }], + }); + access.tokenForAttempt.mockResolvedValue('synthetic'); + repository.reserve.mockResolvedValue(null); + repository.read.mockResolvedValue({ + membershipAt, + membershipRevision: 1, + accounts: [], + work: [], + }); + metadata.savedMany.mockResolvedValue(new Map()); + metadata.request.mockResolvedValue(null); }); - - it('resolves account-scoped access before loading upstream portfolio', async () => { - await service.get('user', 'account'); - await service.get('user', 'account'); - - expect(access.withConnection).toHaveBeenCalledTimes(2); - expect(access.withAccountForConnection).toHaveBeenCalledTimes(1); - expect(tbank.getPortfolio).toHaveBeenCalledTimes(1); - expect(tbank.getPositions).toHaveBeenCalledTimes(1); - }); - - it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => { + it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => { + repository.reserve.mockResolvedValueOnce({ + launch: true, + work: { id: 'work', attemptId: 'attempt' }, + }); tbank.getPortfolio.mockResolvedValue({ positions: [ { - figi: 'ONE', + figi: 'A', + instrumentUid: 'uid', instrumentType: 'share', - quantity: { units: 2 }, - currentPrice: { currency: 'rub', units: 3 }, - averagePositionPrice: { currency: 'RUB', units: 2 }, + quantity: { units: '2' }, + currentPrice: { currency: 'rub', units: '3', nano: 1 }, + averagePositionPrice: { currency: 'RUB', units: '2' }, }, { - figi: 'TWO', + figi: 'B', instrumentType: 'new-kind', quantity: { nano: -500000000 }, - currentPrice: { currency: 'USD', units: 4 }, + currentPrice: { currency: 'USD', units: '4' }, }, - { figi: 'ZERO', quantity: {} }, + { figi: 'zero', instrumentType: 'bond', quantity: { units: '0' } }, ], }); tbank.getPositions.mockResolvedValue({ money: [ - { currency: 'RUB', units: 1 }, - { currency: 'rub', nano: 500000000 }, - { currency: 'USD' }, + { currency: 'rub', units: '1' }, + { currency: 'EUR', units: '0' }, ], }); - tbank.getInstrumentByFigi - .mockResolvedValueOnce({ - ticker: 'ONE', - name: 'Первая позиция', - isin: 'RU000A0JX0J2', - brand: { logoName: 'RU000A0JX0J2x160.png' }, - }) - .mockRejectedValueOnce(new Error('unavailable')); - - await expect(service.get('user', 'account')).resolves.toMatchObject({ - positions: [ + metadata.savedMany.mockResolvedValue( + new Map([ + [ + JSON.stringify(['uid', 'A']), + { data: { name: 'Known', ticker: 'A' } }, + ], + ]), + ); + repository.read.mockResolvedValue({ + membershipAt, + membershipRevision: 1, + accounts: [ { - figi: 'ONE', - displayName: 'Первая позиция', - type: 'STOCK', - quantity: '2', - currentValue: { currency: 'RUB', value: '6' }, - absoluteReturn: { currency: 'RUB', value: '2' }, - isin: 'RU000A0JX0J2', - ticker: 'ONE', - logoName: 'RU000A0JX0J2x160.png', - }, - { - figi: 'TWO', - displayName: 'TWO', - type: 'OTHER', - quantity: '-0.5', - currentValue: { currency: 'USD', value: '-2' }, - isin: null, - ticker: null, - logoName: null, + accountId: 'a', + name: 'A', + type: 'BROKERAGE', + openedOn: null, + portfolio: null, }, ], - cashBalances: [{ currency: 'RUB', value: '1.5' }], - totalsByCurrency: [ - { currency: 'RUB', value: '7.5' }, - { currency: 'USD', value: '-2' }, - ], + work: [], }); - }); - - it('excludes zero and currency-less values while keeping an empty portfolio available', async () => { - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - quantity: { units: 1 }, - currentPrice: { currency: 'RUB' }, - }, - { - quantity: { units: 1 }, - currentPrice: { units: 10 }, - }, - ], - }); - tbank.getPositions.mockResolvedValue({ - money: [{ currency: 'USD' }, { units: 2 }], - }); - - await expect(service.get('user', 'account')).resolves.toMatchObject({ - totalsByCurrency: [], - cashBalances: [], - }); - }); - - it('uses instrument UID before FIGI when resolving a name', async () => { - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'FIGI', - instrumentUid: 'UID', - instrumentType: 'share', - quantity: { units: 1 }, - }, - ], - }); - tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' }); - - await expect(service.get('user', 'account')).resolves.toMatchObject({ - positions: [{ displayName: 'Имя по UID', logoName: null }], - }); - expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); - }); - - it('loads a large mixed portfolio without overflowing metadata requests or losing names', async () => { - const positions = Array.from({ length: 60 }, (_, index) => ({ - figi: `BATCH-${index}`, - instrumentType: index % 2 ? 'bond' : 'share', - quantity: { units: 2 }, - currentPrice: { currency: 'RUB', units: 10 }, - })); - tbank.getPortfolio.mockResolvedValue({ positions }); - let active = 0; - tbank.getInstrumentByFigi.mockImplementation( - async (_token: string, figi: string) => { - active += 1; - try { - if (active > 50) - throw new ServiceUnavailableException({ - code: 'TBANK_UNAVAILABLE', - }); - await Promise.resolve(); - return { - name: `Название ${figi}`, - ticker: figi, - isin: `ISIN-${figi}`, - brand: { logoName: `${figi}.png` }, - }; - } finally { - active -= 1; - } - }, - ); - const result = await service.get('user', 'account'); - expect(result.positions).toMatchObject( - positions.map((position, index) => ({ - displayName: `Название BATCH-${index}`, - type: position.instrumentType === 'share' ? 'STOCK' : 'BOND', - ticker: `BATCH-${index}`, - isin: `ISIN-BATCH-${index}`, - logoName: `BATCH-${index}.png`, - quantity: '2', - currentValue: { currency: 'RUB', value: '20' }, - })), - ); - }); - - it('bounds total metadata waiting and timestamps the financial response before enrichment', async () => { - jest.useFakeTimers({ now: new Date('2026-09-12T12:00:00Z') }); - try { - tbank.getPortfolio.mockResolvedValue({ - positions: Array.from({ length: 60 }, (_, index) => ({ - figi: `BUDGET-${index}`, - instrumentUid: `budget-uid-${index}`, - instrumentType: index % 2 ? 'bond' : 'share', - quantity: { units: 2 }, - currentPrice: { currency: 'RUB', units: 10 }, - })), - }); - tbank.getInstrumentByUid.mockImplementation(() => new Promise(() => {})); - const pending = service.get('user', 'account'); - await jest.advanceTimersByTimeAsync(0); - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5); - await jest.advanceTimersByTimeAsync(30_000); - const result = await pending; - expect(result.lastSuccessfulAt).toBe('2026-09-12T12:00:00Z'); - expect(result.positions).toHaveLength(60); - expect(result.positions[59]).toMatchObject({ - displayName: 'BUDGET-59', - currentValue: { currency: 'RUB', value: '20' }, - }); - expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5); - expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); - } finally { - jest.useRealTimers(); - } - }); - - it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => { - await service.get('user', 'account'); - await service.get('user', 'account'); - expect(access.withConnection).toHaveBeenCalledTimes(2); - expect(access.withAccountForConnection).toHaveBeenCalledTimes(1); - expect(tbank.getPortfolio).toHaveBeenCalledTimes(1); - service.invalidate('user'); - await service.get('user', 'account'); - expect(tbank.getPortfolio).toHaveBeenCalledTimes(2); - const snapshots = ( - service as never as { snapshots: Map } - ).snapshots; - const snapshot = snapshots.get('user:account'); - assert.ok(snapshot); - snapshot.storedAt = Date.now() - 30_001; - tbank.getPortfolio.mockRejectedValueOnce( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - await expect(service.get('user', 'account')).resolves.toMatchObject({ - isStale: true, - }); - }); - - it('does not mask an invalid token with a snapshot', async () => { - await service.get('user', 'account'); - const snapshots = ( - service as never as { snapshots: Map } - ).snapshots; - const snapshot = snapshots.get('user:account'); - assert.ok(snapshot); - snapshot.storedAt = Date.now() - 30_001; - access.withAccountForConnection.mockRejectedValueOnce( - new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }), - ); - await expect(service.get('user', 'account')).rejects.toEqual( - expect.any(BadRequestException), - ); - }); - - it('does not reuse a snapshot or fallback across connection generations', async () => { - await service.get('user', 'account'); - const snapshots = ( - service as never as { snapshots: Map } - ).snapshots; - const snapshot = snapshots.get('user:account'); - assert.ok(snapshot); - snapshot.storedAt = Date.now() - 30_001; - generation = 'generation-2'; - tbank.getPortfolio.mockRejectedValueOnce( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - - await expect(service.get('user', 'account')).rejects.toEqual( - expect.any(ServiceUnavailableException), - ); - }); - - it('keeps current-generation fallback when account resolution is temporarily unavailable', async () => { - await service.get('user', 'account'); - const snapshots = ( - service as never as { snapshots: Map } - ).snapshots; - const snapshot = snapshots.get('user:account'); - assert.ok(snapshot); - snapshot.storedAt = Date.now() - 30_001; - access.withAccountForConnection.mockRejectedValueOnce( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - - await expect(service.get('user', 'account')).resolves.toMatchObject({ - isStale: true, - }); - }); - - it('does not return a snapshot when the current connection is absent', async () => { - await service.get('user', 'account'); - access.withConnection.mockRejectedValueOnce( - new ConflictException({ code: 'TBANK_NOT_CONNECTED' }), - ); - - await expect(service.get('user', 'account')).rejects.toEqual( - expect.any(ConflictException), - ); - }); - - it('does not expose a late previous-generation portfolio snapshot', async () => { - let finishPrevious: ((value: { positions: never[] }) => void) | undefined; - const previous = new Promise<{ positions: never[] }>((resolve) => { - finishPrevious = resolve; - }); - tbank.getPortfolio - .mockReturnValueOnce(previous) - .mockResolvedValueOnce({ positions: [] }) - .mockResolvedValueOnce({ positions: [] }); - tbank.getPositions - .mockResolvedValueOnce({ money: [{ currency: 'RUB', units: 1 }] }) - .mockResolvedValueOnce({ money: [{ currency: 'USD', units: 2 }] }) - .mockResolvedValueOnce({ money: [{ currency: 'USD', units: 3 }] }); - - const previousRequest = service.get('user', 'account'); - generation = 'generation-2'; - const current = await service.get('user', 'account'); - finishPrevious?.({ positions: [] }); - await previousRequest; - const afterLateCompletion = await service.get('user', 'account'); - - expect(current.cashBalances).toEqual([{ currency: 'USD', value: '2' }]); - expect(afterLateCompletion.cashBalances).toEqual([ - { currency: 'USD', value: '3' }, + await service.read('user', 'a'); + for ( + let index = 0; + index < 10 && !repository.publish.mock.calls.length; + index += 1 + ) + await immediate(); + const snapshot = ( + repository.publish.mock.calls as [unknown, string, PortfolioDto][] + )[0][2]; + expect( + snapshot.positions.map((position) => [ + position.displayName, + position.type, + position.quantity, + ]), + ).toEqual([ + ['Known', 'STOCK', '2'], + ['B', 'OTHER', '-0.5'], ]); + expect(snapshot.positions[0].absoluteReturn).toEqual({ + currency: 'RUB', + value: '2.000000002', + }); + expect(snapshot.totalsByCurrency).toEqual([ + { currency: 'RUB', value: '7.000000002' }, + { currency: 'USD', value: '-2' }, + ]); + expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]); + }); + it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => { + const snapshot: PortfolioDto = { + lastSuccessfulAt: membershipAt.toISOString(), + isStale: false, + positions: [], + cashBalances: [], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: 'RUB', value: '1.000000001' }], + }; + const account = { + accountId: 'a', + name: 'A', + type: 'BROKERAGE', + openedOn: null, + portfolio: { + payload: snapshot, + fetchedAt: membershipAt, + dataRevision: 1, + }, + }; + repository.read.mockResolvedValue({ + membershipAt, + membershipRevision: 1, + accounts: [account, { ...account, accountId: 'b', portfolio: null }], + work: [], + }); + const partial = await service.read('user', 'all', true); + expect(partial.data).toBeNull(); + expect(partial.meta.availability).toBe('partial'); + const saved = await service.saved('user', 'all', true); + expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual( + snapshot.totalsByCurrency, + ); + expect(saved.accounts[1].snapshot).toBeNull(); + repository.read.mockResolvedValue({ + membershipAt, + membershipRevision: 1, + accounts: [ + account, + { + ...account, + accountId: 'b', + portfolio: { ...account.portfolio, fetchedAt: new Date(0) }, + }, + ], + work: [], + }); + const complete = await service.read('user', 'all', true); + expect(complete.data?.totalsByCurrency).toEqual([ + { currency: 'RUB', value: '2.000000002' }, + ]); + expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString()); + expect(complete.data?.isStale).toBe(true); + expect(tbank.getPortfolio).not.toHaveBeenCalled(); + }); + it('keeps pending data unavailable to internal financial consumers', async () => { + repository.read.mockResolvedValue({ + membershipAt: null, + membershipRevision: 0, + accounts: [], + work: [], + }); + await expect(service.getAll('user')).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); }); }); diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index b95acb5..42798ca 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -1,4 +1,17 @@ -import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { CandleService } from '../../instruments/instruments.api'; +import { randomUUID } from 'node:crypto'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { + PortfolioRepository, + portfolioFreshForMs, + portfolioWorkKey, +} from '../infrastructure/portfolio.repository'; +import { + Injectable, + ServiceUnavailableException, + NotFoundException, +} from '@nestjs/common'; import { add, formatDecimal, @@ -10,10 +23,14 @@ import { RequestContextService } from '../../../../common/logging/request-contex import { MoneyDto } from '../../../../common/money.dto'; import { InstrumentMetadataService, + metadataKey, + type TbankInstrument, + type MetadataIdentity, TbankAccessService, TbankClient, - TbankConnectionContext, TbankMoney, + TbankMembershipService, + tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; import { decimal } from '../../../../integrations/tbank/decimal'; import { portfolioStructure } from '../domain/portfolio-structure'; @@ -24,10 +41,7 @@ import { PortfolioPositionDto, } from '../presentation/portfolio.dto'; -const freshForMs = 30_000; -const fallbackForMs = 300_000; -const positionsBatchSize = 5; -const metadataTimeoutMs = 30_000; +const membershipFreshForMs = tbankSourceTtlMs.membership; const types: Record = { share: 'STOCK', bond: 'BOND', @@ -39,116 +53,358 @@ const types: Record = { dfa: 'DIGITAL_FINANCIAL_ASSET', }; -type CachedSnapshot = { - generation: string; - snapshot: PortfolioDto; - storedAt: number; -}; - @Injectable() export class PortfolioService { - private readonly snapshots = new Map(); + private readonly processRunId = randomUUID(); + private readonly active = new Set(); + private readonly pending = new Map>(); + private reservations: Promise = Promise.resolve(); constructor( private readonly access: TbankAccessService, private readonly tbank: TbankClient, private readonly requestContext: RequestContextService, private readonly metadata: InstrumentMetadataService, + private readonly repository: PortfolioRepository, + private readonly membership: TbankMembershipService, + private readonly logger: StructuredLogger, + private readonly candles: CandleService, ) {} - async get(userId: string, accountId: string): Promise { + async saved(userId: string, accountId: string, observe = false) { this.requestContext.set({ userId, brokerageAccountId: accountId }); - return this.access.withConnection(userId, (connection) => - this.snapshot(userId, accountId, connection), - ); - } - - async getAll(userId: string): Promise { - return this.access.withSupportedAccounts(userId, async (context) => { - const portfolios: PortfolioDto[] = []; - for (const account of context.accounts) { - const portfolio = await this.snapshot(userId, account.id, context); - portfolios.push({ - ...portfolio, - positions: portfolio.positions.map((position) => ({ - ...position, - account: { id: account.id, name: account.name }, - })), - }); - } - return { - lastSuccessfulAt: - portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? - new Date().toISOString(), - isStale: portfolios.some((portfolio) => portfolio.isStale), - totalsByCurrency: sumMoneyByCurrency( - portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), - ), - cashBalances: sumMoneyByCurrency( - portfolios.flatMap((portfolio) => portfolio.cashBalances), - ), - positions: portfolios.flatMap((portfolio) => portfolio.positions), - structure: portfolioStructure( - portfolios.flatMap((portfolio) => portfolio.positions), - portfolios.flatMap((portfolio) => portfolio.cashBalances), - ), - }; - }); - } - - private async snapshot( - userId: string, - accountId: string, - { token, generation }: TbankConnectionContext, - ): Promise { - const key = `${userId}:${accountId}`; - const cached = this.snapshots.get(key); - if ( - cached?.generation === generation && - Date.now() - cached.storedAt <= freshForMs - ) - return cached.snapshot; - try { - const snapshot = await this.access.withAccountForConnection( - { token, generation }, - accountId, - async () => { - const [portfolio, positions] = await Promise.all([ - this.tbank.getPortfolio(token, accountId), - this.tbank.getPositions(token, accountId), - ]); - return this.normalize( - token, - portfolio.positions ?? [], - positions.money ?? [], - ); - }, + const connection = await this.access.local(userId, accountId); + if (!observe) { + const discovery = await this.membership.request( + userId, + connection.revision, ); - this.snapshots.set(key, { - generation, - snapshot, - storedAt: Date.parse(snapshot.lastSuccessfulAt), - }); - return snapshot; - } catch (error) { - if ( - error instanceof ServiceUnavailableException && - cached?.generation === generation && - Date.now() - cached.storedAt <= fallbackForMs - ) { - return { ...cached.snapshot, isStale: true }; - } - throw error; + if (discovery) + void discovery.done + .then(async () => { + const current = await this.access.local(userId, accountId); + if (current.revision !== connection.revision) return; + for (const account of current.accounts.filter( + (account) => + accountId === 'all' || account.accountId === accountId, + )) + await this.queueSnapshot( + userId, + current.revision, + account.accountId, + ); + }) + .catch(() => + this.logger.event( + 'warn', + 'tbank.portfolio.discovery_not_available', + { userId }, + ), + ); + for (const account of (discovery ? [] : connection.accounts).filter( + (account) => accountId === 'all' || account.accountId === accountId, + )) + await this.queueSnapshot( + userId, + connection.revision, + account.accountId, + ); } + const saved = await this.repository.read(userId, connection.revision); + if ( + saved?.membershipAt && + accountId !== 'all' && + !saved.accounts.some((account) => account.accountId === accountId) + ) + throw new NotFoundException(); + if (!observe) { + const identities = new Map(); + for (const account of (saved?.accounts ?? []).filter( + (account) => accountId === 'all' || account.accountId === accountId, + )) { + const snapshot = account.portfolio?.payload as unknown as + PortfolioDto | undefined; + for (const position of snapshot?.positions ?? []) + identities.set(metadataKey(position), position); + } + const savedMetadata = await this.metadata.savedMany([ + ...identities.values(), + ]); + for (const identity of identities.values()) + if (!savedMetadata.get(metadataKey(identity))?.fresh) + await this.metadata.request(userId, connection.revision, identity); + } + if (!observe) + void this.candles + .prefetch( + userId, + connection.revision, + (saved?.accounts ?? []) + .filter( + (account) => + accountId === 'all' || account.accountId === accountId, + ) + .flatMap( + (account) => + ( + account.portfolio?.payload as unknown as + PortfolioDto | undefined + )?.positions ?? [], + ), + ) + .catch(() => + this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'), + ); + const membershipMeta = investmentResourceMeta({ + resourceKey: 'accounts/membership', + dataRevision: String(saved?.membershipRevision ?? 0), + availability: saved?.membershipAt ? 'complete' : 'missing', + fetchedAt: saved?.membershipAt ?? null, + freshForMs: membershipFreshForMs, + work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null, + }); + return { + membership: membershipMeta, + accounts: (saved?.accounts ?? []) + .filter( + (account) => accountId === 'all' || account.accountId === accountId, + ) + .map((account) => ({ + id: account.accountId, + name: account.name, + type: account.type, + openedOn: account.openedOn, + snapshot: account.portfolio + ? { + ...(account.portfolio.payload as unknown as PortfolioDto), + lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(), + isStale: + Date.now() - account.portfolio.fetchedAt.valueOf() >= + portfolioFreshForMs, + } + : null, + meta: investmentResourceMeta({ + resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, + dataRevision: String(account.portfolio?.dataRevision ?? 0), + availability: account.portfolio ? 'complete' : 'missing', + fetchedAt: account.portfolio?.fetchedAt ?? null, + freshForMs: portfolioFreshForMs, + work: + saved?.work.find( + (work) => work.scopeKey === portfolioWorkKey(account.accountId), + ) ?? null, + }), + })), + }; } - invalidate(userId: string): void { - for (const key of this.snapshots.keys()) - if (key.startsWith(`${userId}:`)) this.snapshots.delete(key); + async read(userId: string, accountId: string, observe = false) { + const saved = await this.saved(userId, accountId, observe); + const missing = + saved.membership.availability === 'missing' || + saved.accounts.some((account) => !account.snapshot); + const metadata = await this.metadata.savedMany( + saved.accounts.flatMap((account) => account.snapshot?.positions ?? []), + ); + const metadataDependencies = [...metadata.entries()].flatMap( + ([key, source]) => + source.complete && source.data + ? [ + investmentResourceMeta({ + resourceKey: `instrument/metadata/${key}`, + dataRevision: source.dataRevision, + availability: 'complete', + fetchedAt: source.fetchedAt, + freshForMs: tbankSourceTtlMs.metadata, + work: null, + }), + ] + : [], + ); + const portfolios = saved.accounts.flatMap((account) => + account.snapshot + ? [ + { + ...account.snapshot, + positions: account.snapshot.positions.map((position) => { + const shared = metadata.get(metadataKey(position))?.data; + return { + ...position, + ...(shared + ? { + displayName: + shared.name?.trim() || + shared.ticker?.trim() || + position.displayName, + ticker: shared.ticker?.trim() || position.ticker, + isin: shared.isin?.trim() || position.isin, + logoName: + shared.brand?.logoName?.trim() || position.logoName, + } + : {}), + ...(accountId === 'all' + ? { account: { id: account.id, name: account.name } } + : {}), + }; + }), + }, + ] + : [], + ); + const lastSuccessfulAt = + portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? + saved.membership.lastSuccessfulAt; + const data = + missing || !lastSuccessfulAt + ? null + : { + lastSuccessfulAt, + isStale: portfolios.length + ? portfolios.some((portfolio) => portfolio.isStale) + : saved.membership.freshness !== 'fresh', + totalsByCurrency: sumMoneyByCurrency( + portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), + ), + cashBalances: sumMoneyByCurrency( + portfolios.flatMap((portfolio) => portfolio.cashBalances), + ), + positions: portfolios.flatMap((portfolio) => portfolio.positions), + structure: portfolioStructure( + portfolios.flatMap((portfolio) => portfolio.positions), + portfolios.flatMap((portfolio) => portfolio.cashBalances), + ), + }; + const work = + saved.accounts + .map((account) => account.meta.sync) + .find((work) => work.state === 'running') ?? + saved.accounts + .map((account) => account.meta.sync) + .find((work) => work.state === 'failed') ?? + saved.membership.sync; + return { + data, + meta: investmentResourceMeta({ + resourceKey: `portfolio/${encodeURIComponent(accountId)}`, + dataRevision: JSON.stringify([ + saved.accounts.map((account) => [ + account.id, + account.meta.dataRevision, + ]), + metadataDependencies.map((dependency) => dependency.dataRevision), + ]), + availability: missing + ? portfolios.length + ? 'partial' + : 'missing' + : 'complete', + fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, + freshForMs: portfolios.length + ? portfolioFreshForMs + : membershipFreshForMs, + work: work.id ? { ...work, id: work.id } : null, + dependencies: [ + saved.membership, + ...saved.accounts.map((account) => account.meta), + ...metadataDependencies, + ], + }), + }; + } + + request(userId: string, revision: string, accountId: string, force = false) { + return this.queueSnapshot(userId, revision, accountId, force); + } + + private queueSnapshot( + userId: string, + revision: string, + accountId: string, + force = false, + ) { + const reserved = this.reservations.then(async () => { + const requested = await this.repository.reserve( + userId, + revision, + accountId, + this.processRunId, + this.active, + force, + ); + if (!requested) return null; + if (!requested.launch) + return { + id: requested.work.id, + done: this.pending.get(requested.work.attemptId) ?? Promise.resolve(), + }; + const { id, attemptId } = requested.work; + this.active.add(attemptId); + const done = new Promise((resolve) => setImmediate(resolve)) + .then(async () => { + const attempt = { id, attemptId, userId, revision }; + try { + const token = await this.access.tokenForAttempt(attempt); + if (!token) return; + const [portfolio, positions] = await Promise.all([ + this.tbank.getPortfolio(token, accountId), + this.tbank.getPositions(token, accountId), + ]); + const snapshot = await this.normalize( + portfolio.positions ?? [], + positions.money ?? [], + ); + const published = await this.repository.publish( + attempt, + accountId, + snapshot, + ); + if (published) { + for (const position of snapshot.positions) + await this.metadata.request(userId, revision, position); + if (!force) + await this.candles.prefetch( + userId, + revision, + snapshot.positions, + ); + } + } catch (error) { + if ( + error instanceof Error && + error.message === 'INVALID_TBANK_TOKEN' + ) + await this.access.rejectMembershipToken(attempt); + else await this.repository.fail(attempt); + } + }) + .catch(() => + this.logger.event('error', 'tbank.portfolio.persistence_failed', { + userId, + }), + ) + .finally(() => { + this.active.delete(attemptId); + this.pending.delete(attemptId); + }); + this.pending.set(attemptId, done); + return { id, done }; + }); + this.reservations = reserved.catch(() => undefined); + return reserved; + } + + async get(userId: string, accountId: string): Promise { + const result = await this.read(userId, accountId); + if (!result.data) + throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + return result.data; + } + + getAll(userId: string): Promise { + return this.get(userId, 'all'); } private async normalize( - token: string, upstream: { figi?: string; instrumentUid?: string; @@ -160,28 +416,13 @@ export class PortfolioService { money: TbankMoney[], ): Promise { const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), metadataTimeoutMs); - const positions: (PortfolioPositionDto | null)[] = []; - try { - for ( - let offset = 0; - offset < upstream.length; - offset += positionsBatchSize - ) { - positions.push( - ...(await Promise.all( - upstream - .slice(offset, offset + positionsBatchSize) - .map((position) => - this.position(token, position, controller.signal), - ), - )), - ); - } - } finally { - clearTimeout(timeout); - } + const savedMetadata = await this.metadata.savedMany(upstream); + const positions = upstream.map((position) => + this.position( + position, + savedMetadata.get(metadataKey(position))?.data ?? null, + ), + ); const cash = new Map>(); for (const value of money) { const currency = value.currency?.trim().toUpperCase(); @@ -206,11 +447,10 @@ export class PortfolioService { }; } - private async position( - token: string, - position: Parameters[1][number], - signal: AbortSignal, - ): Promise { + private position( + position: Parameters[0][number], + metadata: TbankInstrument | null, + ): PortfolioPositionDto | null { const quantity = decimal(position.quantity ?? {}); if (isZero(quantity)) return null; const figi = position.figi?.trim() || null; @@ -235,12 +475,6 @@ export class PortfolioService { ), } : null; - const metadata = await this.metadata.resolve( - token, - instrumentUid, - figi, - signal, - ); return { figi, instrumentUid, diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts new file mode 100644 index 0000000..ae95b21 --- /dev/null +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -0,0 +1,154 @@ +import { Injectable } from '@nestjs/common'; +import { isDeepStrictEqual } from 'node:util'; +import type { Prisma } from '../../../../../generated/prisma/client'; +import { PrismaService } from '../../../../prisma/prisma.service'; + +export type ForecastSourceVersion = { + instrumentUid: string; + source: string; + kind: string; + rangeKey: string; + dataRevision: number; + fetchedAt: Date; +}; +const forecastSourceReadBatchSize = 100; + +@Injectable() +export class ForecastRepository { + constructor(private readonly prisma: PrismaService) {} + + resolve(input: { + userId: string; + revision: string; + membershipRevision: number; + scopeKey: string; + candidate?: object; + portfolioProjection: string; + portfolios: { accountId: string; dataRevision: number }[]; + sources: ForecastSourceVersion[]; + timestamps: { resourceKey: string; at: string }[]; + }) { + return this.prisma.$transaction(async (tx) => { + const owner = { userId: input.userId, revision: input.revision }; + if (input.candidate) { + const current = await tx.tbankConnection.updateMany({ + where: { + ...owner, + membershipRevision: input.membershipRevision, + invalidToken: false, + }, + data: { membershipRevision: { increment: 0 } }, + }); + if (!current.count) return null; + } + const connection = await tx.tbankConnection.findFirst({ + where: { + ...owner, + membershipRevision: input.membershipRevision, + invalidToken: false, + }, + }); + if (!connection) return null; + const key = { ...owner, scopeKey: input.scopeKey }; + const previous = await tx.tbankForecast.findUnique({ + where: { userId_revision_scopeKey: key }, + }); + const fallback = + previous?.membershipRevision === input.membershipRevision + ? previous + : null; + if (!input.candidate) return fallback; + const portfolios = await tx.tbankPortfolio.findMany({ + where: { + ...owner, + accountId: { in: input.portfolios.map((row) => row.accountId) }, + }, + select: { accountId: true, dataRevision: true }, + }); + if ( + input.portfolios.some( + (expected) => + !portfolios.some( + (row) => + row.accountId === expected.accountId && + row.dataRevision === expected.dataRevision, + ), + ) + ) + return fallback; + // Four identity parameters per source: stay below SQLite's parameter limit. + const sources: ForecastSourceVersion[] = []; + for ( + let offset = 0; + offset < input.sources.length; + offset += forecastSourceReadBatchSize + ) + sources.push( + ...(await tx.tbankInstrumentSource.findMany({ + where: { + provider: 'tbank', + OR: input.sources + .slice(offset, offset + forecastSourceReadBatchSize) + .map(({ instrumentUid, source, kind, rangeKey }) => ({ + instrumentUid, + source, + kind, + rangeKey, + })), + }, + select: { + instrumentUid: true, + source: true, + kind: true, + rangeKey: true, + dataRevision: true, + fetchedAt: true, + }, + })), + ); + if ( + input.sources.some( + (expected) => + !sources.some( + (row) => + row.instrumentUid === expected.instrumentUid && + row.source === expected.source && + row.kind === expected.kind && + row.rangeKey === expected.rangeKey && + row.dataRevision === expected.dataRevision && + row.fetchedAt.valueOf() === expected.fetchedAt.valueOf(), + ), + ) + ) + return fallback; + const revisions = JSON.parse( + JSON.stringify({ + portfolioProjection: input.portfolioProjection, + portfolios: input.portfolios, + sources: input.sources, + }), + ) as Prisma.InputJsonObject; + const timestamps = input.timestamps as Prisma.InputJsonArray; + if ( + fallback && + isDeepStrictEqual(fallback.dependencyRevisions, revisions) && + isDeepStrictEqual(fallback.sourceTimestamps, timestamps) + ) + return fallback; + const data = { + membershipRevision: input.membershipRevision, + payload: JSON.parse( + JSON.stringify(input.candidate), + ) as Prisma.InputJsonObject, + dependencyRevisions: revisions, + sourceTimestamps: timestamps, + calculatedAt: new Date(), + }; + return tx.tbankForecast.upsert({ + where: { userId_revision_scopeKey: key }, + create: { ...key, ...data }, + update: { ...data, dataRevision: { increment: 1 } }, + }); + }); + } +} diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts new file mode 100644 index 0000000..b7ffe04 --- /dev/null +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -0,0 +1,157 @@ +import { + tbankSourceTtlMs, + tbankSourceNeedsRefresh, +} from '../../../../integrations/tbank/tbank.api'; +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { PrismaService } from '../../../../prisma/prisma.service'; +import type { Prisma } from '../../../../../generated/prisma/client'; +import type { PortfolioDto } from '../presentation/portfolio.dto'; + +type PortfolioAttempt = { + id: string; + attemptId: string; + userId: string; + revision: string; +}; +export const portfolioFreshForMs = tbankSourceTtlMs.portfolio; + +@Injectable() +export class PortfolioRepository { + constructor(private readonly prisma: PrismaService) {} + + read(userId: string, revision: string) { + return this.prisma.$transaction((tx) => + tx.tbankConnection.findUnique({ + where: { userId_revision: { userId, revision }, invalidToken: false }, + select: { + membershipAt: true, + membershipRevision: true, + accounts: { + orderBy: { upstreamOrder: 'asc' }, + include: { portfolio: true }, + }, + work: { + where: { + OR: [ + { scopeKey: 'membership' }, + { scopeKey: { startsWith: 'portfolio:' } }, + ], + }, + select: { scopeKey: true, id: true, state: true, errorCode: true }, + }, + }, + }), + ); + } + + reserve( + userId: string, + revision: string, + accountId: string, + processRunId: string, + active: ReadonlySet, + force: boolean, + ) { + return this.prisma.$transaction(async (tx) => { + const current = await tx.tbankConnection.updateMany({ + where: { + userId, + revision, + invalidToken: false, + accounts: { some: { accountId } }, + }, + data: { membershipRevision: { increment: 0 } }, + }); + if (!current.count) return null; + const snapshot = await tx.tbankPortfolio.findUnique({ + where: { userId_revision_accountId: { userId, revision, accountId } }, + }); + if ( + !force && + snapshot && + !tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) + ) + return null; + const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) }; + const previous = await tx.tbankSyncWork.findUnique({ + where: { userId_revision_scopeKey: scope }, + }); + if ( + previous?.state === 'running' && + previous.processRunId === processRunId && + active.has(previous.attemptId) + ) + return { work: previous, launch: false }; + const data = { + state: 'running', + attemptId: randomUUID(), + processRunId, + priority: 1, + intent: { source: 'portfolio', accountId }, + progress: {}, + errorCode: null, + }; + return { + work: await tx.tbankSyncWork.upsert({ + where: { userId_revision_scopeKey: scope }, + create: { ...scope, ...data, id: randomUUID() }, + update: data, + }), + launch: true, + }; + }); + } + + publish( + attempt: PortfolioAttempt, + accountId: string, + snapshot: PortfolioDto, + ) { + return this.prisma.$transaction(async (tx) => { + const claimed = await tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { + invalidToken: false, + accounts: { some: { accountId } }, + }, + }, + data: { state: 'complete', progress: {} }, + }); + if (!claimed.count) return false; + const scope = { + userId: attempt.userId, + revision: attempt.revision, + accountId, + }; + const data = { + payload: JSON.parse(JSON.stringify(snapshot)) as Prisma.InputJsonObject, + fetchedAt: new Date(snapshot.lastSuccessfulAt), + }; + await tx.tbankPortfolio.upsert({ + where: { userId_revision_accountId: scope }, + create: { ...scope, ...data }, + update: { ...data, dataRevision: { increment: 1 } }, + }); + return true; + }); + } + + async fail(attempt: PortfolioAttempt) { + const result = await this.prisma.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, + }); + return result.count === 1; + } +} + +export function portfolioWorkKey(accountId: string): string { + return `portfolio:${JSON.stringify(accountId)}`; +} diff --git a/src/modules/investments/portfolio/portfolio.api.ts b/src/modules/investments/portfolio/portfolio.api.ts index e7fbf20..15fb504 100644 --- a/src/modules/investments/portfolio/portfolio.api.ts +++ b/src/modules/investments/portfolio/portfolio.api.ts @@ -5,3 +5,5 @@ export { PortfolioPositionDto, } from './presentation/portfolio.dto'; export { PortfolioService } from './application/portfolio.service'; + +export { ForecastService } from './application/forecast.service'; diff --git a/src/modules/investments/portfolio/portfolio.module.ts b/src/modules/investments/portfolio/portfolio.module.ts index d053151..5f77166 100644 --- a/src/modules/investments/portfolio/portfolio.module.ts +++ b/src/modules/investments/portfolio/portfolio.module.ts @@ -1,3 +1,7 @@ +import { ForecastService } from './application/forecast.service'; +import { ForecastRepository } from './infrastructure/forecast.repository'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { PortfolioRepository } from './infrastructure/portfolio.repository'; import { Module } from '@nestjs/common'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; @@ -5,9 +9,14 @@ import { PortfolioController } from './presentation/portfolio.controller'; import { PortfolioService } from './application/portfolio.service'; @Module({ - imports: [AuthModule, TbankModule], + imports: [AuthModule, TbankModule, InstrumentSourcesModule], controllers: [PortfolioController], - providers: [PortfolioService], - exports: [PortfolioService], + providers: [ + PortfolioService, + PortfolioRepository, + ForecastService, + ForecastRepository, + ], + exports: [PortfolioService, ForecastService], }) export class PortfolioModule {} diff --git a/src/modules/investments/portfolio/presentation/portfolio.controller.ts b/src/modules/investments/portfolio/presentation/portfolio.controller.ts index a136036..942dd6a 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.controller.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.controller.ts @@ -1,9 +1,10 @@ -import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; +import { InvestmentReadQueryDto } from '../../investment-resource.dto'; +import { Controller, Get, Param, Req, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; -import { PortfolioDto } from './portfolio.dto'; +import { PortfolioResourceDto } from './portfolio.dto'; import { PortfolioService } from '../application/portfolio.service'; @ApiTags('brokerage-accounts') @@ -15,17 +16,29 @@ export class PortfolioController { constructor(private readonly portfolio: PortfolioService) {} @Get('all/portfolio') - @ApiOkResponse({ type: PortfolioDto }) - all(@Req() request: Request & { user: AccessPayload }) { - return this.portfolio.getAll(request.user.sub); + @ApiOkResponse({ type: PortfolioResourceDto }) + all( + @Req() request: Request & { user: AccessPayload }, + @Query() query: InvestmentReadQueryDto, + ) { + return this.portfolio.read( + request.user.sub, + 'all', + query.sync === 'observe', + ); } @Get(':accountId/portfolio') - @ApiOkResponse({ type: PortfolioDto }) + @ApiOkResponse({ type: PortfolioResourceDto }) get( @Req() request: Request & { user: AccessPayload }, @Param('accountId') accountId: string, + @Query() query: InvestmentReadQueryDto, ) { - return this.portfolio.get(request.user.sub, accountId); + return this.portfolio.read( + request.user.sub, + accountId, + query.sync === 'observe', + ); } } diff --git a/src/modules/investments/portfolio/presentation/portfolio.dto.ts b/src/modules/investments/portfolio/presentation/portfolio.dto.ts index 5683490..a7a8224 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.dto.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.dto.ts @@ -1,3 +1,4 @@ +import { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { MoneyDto } from '../../../../common/money.dto'; import { AccountReferenceDto } from '../../account-reference.dto'; @@ -123,3 +124,10 @@ export class PortfolioDto { @ApiProperty({ type: PortfolioStructureDto }) declare structure: PortfolioStructureDto; } + +export class PortfolioResourceDto { + @ApiProperty({ type: PortfolioDto, nullable: true }) + declare data: PortfolioDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} diff --git a/src/modules/investments/tbank-connection/tbank-connection.module.ts b/src/modules/investments/tbank-connection/tbank-connection.module.ts index b58c750..2f6de7f 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.module.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.module.ts @@ -1,12 +1,11 @@ import { Module } from '@nestjs/common'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; -import { PortfolioModule } from '../portfolio/portfolio.module'; import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionService } from './tbank-connection.service'; @Module({ - imports: [AuthModule, TbankModule, PortfolioModule], + imports: [AuthModule, TbankModule], controllers: [TbankConnectionController], providers: [TbankConnectionService], }) diff --git a/src/modules/investments/tbank-connection/tbank-connection.service.spec.ts b/src/modules/investments/tbank-connection/tbank-connection.service.spec.ts deleted file mode 100644 index 8056d6d..0000000 --- a/src/modules/investments/tbank-connection/tbank-connection.service.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; - -jest.mock('../../../prisma/prisma.service', () => ({ - PrismaService: class PrismaService {}, -})); - -import type { TbankClient } from '../../../integrations/tbank/tbank.client'; -import { TbankConnectionService } from './tbank-connection.service'; - -describe('TbankConnectionService', () => { - const prisma = { - tbankConnection: { - findUnique: jest.fn(), - upsert: jest.fn(), - deleteMany: jest.fn(), - }, - }; - const tokens = { - encrypt: jest.fn((value: string) => `encrypted:${value}`), - translateInvalidToken: jest.fn( - async (operation: () => Promise) => { - try { - return await operation(); - } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') - throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); - throw error; - } - }, - ), - }; - const tbank: jest.Mocked> = { - validateToken: jest.fn(), - }; - const portfolio = { invalidate: jest.fn() }; - const service = new TbankConnectionService( - prisma as never, - tokens as never, - tbank, - portfolio as never, - ); - - beforeEach(() => { - jest.clearAllMocks(); - tbank.validateToken.mockResolvedValue(undefined); - }); - - it('does not persist an invalid token', async () => { - tbank.validateToken.mockRejectedValueOnce(new Error('INVALID_TBANK_TOKEN')); - - await expect(service.put('user-1', ' token ')).rejects.toEqual( - expect.any(BadRequestException), - ); - expect(prisma.tbankConnection.upsert).not.toHaveBeenCalled(); - }); - - it('validates and encrypts a candidate token before persisting it', async () => { - const connectedAt = new Date('2026-08-25T09:00:00.000Z'); - prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt }); - - await expect(service.put('user-1', ' token ')).resolves.toEqual({ - connected: true, - connectedAt: '2026-08-25T09:00:00.000Z', - }); - - expect(tokens.translateInvalidToken).toHaveBeenCalledWith( - expect.any(Function), - ); - expect(tbank.validateToken).toHaveBeenCalledWith('token'); - expect(tokens.encrypt).toHaveBeenCalledWith('token'); - expect(prisma.tbankConnection.upsert).toHaveBeenCalledWith({ - where: { userId: 'user-1' }, - create: { - userId: 'user-1', - encryptedToken: 'encrypted:token', - connectedAt: expect.any(Date) as Date, - }, - update: { - encryptedToken: 'encrypted:token', - connectedAt: expect.any(Date) as Date, - }, - }); - expect(portfolio.invalidate).toHaveBeenCalledWith('user-1'); - }); - - it('re-encrypts the same plaintext token for every successful replacement', async () => { - const connectedAt = new Date('2026-08-25T09:00:00.000Z'); - prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt }); - tokens.encrypt - .mockReturnValueOnce('encrypted:first-nonce') - .mockReturnValueOnce('encrypted:second-nonce'); - - await service.put('user-1', 'same-token'); - await service.put('user-1', 'same-token'); - - expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - update: expect.objectContaining({ - encryptedToken: 'encrypted:first-nonce', - }) as object, - }), - ); - expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - update: expect.objectContaining({ - encryptedToken: 'encrypted:second-nonce', - }) as object, - }), - ); - }); - - it('deletes a connection idempotently', async () => { - await service.delete('user-1'); - - expect(prisma.tbankConnection.deleteMany).toHaveBeenCalledWith({ - where: { userId: 'user-1' }, - }); - expect(portfolio.invalidate).toHaveBeenCalledWith('user-1'); - }); -}); diff --git a/src/modules/investments/tbank-connection/tbank-connection.service.ts b/src/modules/investments/tbank-connection/tbank-connection.service.ts index 024cced..a048821 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.service.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.ts @@ -2,9 +2,9 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { TbankClient, TbankTokenService, + supportedTbankAccounts, } from '../../../integrations/tbank/tbank.api'; import { PrismaService } from '../../../prisma/prisma.service'; -import { PortfolioService } from '../portfolio/portfolio.api'; type TbankTokenValidator = Pick; @@ -14,7 +14,6 @@ export class TbankConnectionService { private readonly prisma: PrismaService, private readonly tokens: TbankTokenService, @Inject(TbankClient) private readonly tbank: TbankTokenValidator, - private readonly portfolio: PortfolioService, ) {} async status(userId: string) { @@ -30,23 +29,34 @@ export class TbankConnectionService { async put(userId: string, value: string) { const token = value.trim(); if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' }); - await this.tokens.translateInvalidToken(() => + const accounts = await this.tokens.translateInvalidToken(() => this.tbank.validateToken(token), ); const encryptedToken = this.tokens.encrypt(token); - const connection = await this.prisma.tbankConnection.upsert({ - where: { userId }, - create: { - userId, - encryptedToken, - connectedAt: new Date(), - }, - update: { - encryptedToken, - connectedAt: new Date(), - }, + const connection = await this.prisma.$transaction(async (tx) => { + // Cascade old private data and attempts in the same transaction that + // publishes the validated membership and the new connection revision. + await tx.tbankConnection.deleteMany({ where: { userId } }); + const now = new Date(); + return tx.tbankConnection.create({ + data: { + userId, + encryptedToken, + connectedAt: now, + membershipAt: now, + membershipRevision: 1, + accounts: { + create: supportedTbankAccounts(accounts).map( + ({ id, ...account }, upstreamOrder) => ({ + ...account, + accountId: id, + upstreamOrder, + }), + ), + }, + }, + }); }); - this.portfolio.invalidate(userId); return { connected: true, connectedAt: connection.connectedAt.toISOString(), @@ -55,6 +65,5 @@ export class TbankConnectionService { async delete(userId: string): Promise { await this.prisma.tbankConnection.deleteMany({ where: { userId } }); - this.portfolio.invalidate(userId); } } diff --git a/src/modules/investments/tbank-sync/tbank-screen.spec.ts b/src/modules/investments/tbank-sync/tbank-screen.spec.ts new file mode 100644 index 0000000..bf0cc8d --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-screen.spec.ts @@ -0,0 +1,65 @@ +import { normalizeScreen } from './tbank-screen'; + +describe('manual sync screen boundary', () => { + it('normalizes equivalent descriptors to the same scope', () => { + expect(normalizeScreen({ screen: 'stocks', query: ' sber ' })).toEqual({ + screen: 'stocks', + query: 'SBER', + }); + expect( + JSON.stringify( + normalizeScreen({ + screen: 'operations', + accountId: 'a', + categories: ['tax', 'income', 'tax'], + limit: 20, + }), + ), + ).toBe( + JSON.stringify( + normalizeScreen({ + limit: 20, + categories: ['income', 'tax'], + accountId: 'a', + screen: 'operations', + }), + ), + ); + }); + it('rejects missing identities and fields that would expand another screen', () => { + for (const scope of [ + { screen: 'accounts', accountId: 'a' }, + { screen: 'account' }, + { screen: 'stock' }, + { screen: 'stocks', query: ' ' }, + { screen: 'events', accountId: 'a', instrumentUid: 'uid' }, + { screen: 'income', accountId: 'a', from: '2026-01-01' }, + { + screen: 'events', + accountId: 'a', + from: '2026-02-01', + to: '2026-01-01', + }, + ] as Parameters[0][]) + expect(() => normalizeScreen(scope)).toThrow(); + }); + it('retains inclusive calendar days, rejecting instant values for date-only owners', () => { + const scope = { + screen: 'events' as const, + accountId: 'all', + from: '2026-01-01', + to: '2026-01-01', + }; + expect(normalizeScreen(scope)).toEqual(scope); + expect(() => + normalizeScreen({ ...scope, from: '2026-01-01T00:00:00Z' }), + ).toThrow(); + expect(() => + normalizeScreen({ + screen: 'stock', + instrumentUid: 'uid', + candleRange: { from: '2026-01-02', to: '2026-01-01' }, + }), + ).toThrow(); + }); +}); diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts new file mode 100644 index 0000000..166d083 --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -0,0 +1,101 @@ +import { BadRequestException } from '@nestjs/common'; +import type { InvestmentScreenDto } from './tbank-sync.dto'; + +// Reject fields from other screens before reserving any source work. +export function normalizeScreen( + input: InvestmentScreenDto, +): InvestmentScreenDto { + const commonPeriod = ['from', 'to']; + const ledger = [...commonPeriod, 'categories', 'limit']; + const allowed: Record = { + accounts: [], + account: ['accountId', ...ledger], + operations: ['accountId', 'instrumentUid', ...ledger], + income: ['accountId', ...commonPeriod], + events: ['accountId', ...commonPeriod], + stocks: ['query'], + bonds: ['query'], + stock: [ + 'accountId', + 'instrumentUid', + ...ledger, + 'candlePeriod', + 'candleRange', + ], + bond: [ + 'accountId', + 'instrumentUid', + ...ledger, + 'candlePeriod', + 'candleRange', + ], + }; + const fail = (): never => { + throw new BadRequestException({ + message: [ + { + property: 'scope', + constraints: { scope: 'Invalid screen descriptor' }, + }, + ], + }); + }; + if (!allowed[input.screen]) fail(); + for (const key of Object.keys(input)) + if ( + key !== 'screen' && + input[key as keyof InvestmentScreenDto] !== undefined && + !allowed[input.screen].includes(key) + ) + fail(); + const accountId = input.accountId?.trim(); + const query = input.query?.trim().toUpperCase(); + if ( + ['account', 'operations', 'income', 'events'].includes(input.screen) && + !accountId + ) + fail(); + if ( + ['stocks', 'bonds'].includes(input.screen) && + (!query || query.length < 2) + ) + fail(); + if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail(); + if (input.accountId !== undefined && !accountId) fail(); + if (['income', 'events'].includes(input.screen)) { + if (Boolean(input.from) !== Boolean(input.to)) fail(); + for (const value of [input.from, input.to]) + if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail(); + } + if (input.from && input.to && new Date(input.from) > new Date(input.to)) + fail(); + if ( + input.candleRange && + new Date(input.candleRange.from) >= new Date(input.candleRange.to) + ) + fail(); + // Fixed field order is part of coalescing, irrespective of JSON input order. + return { + screen: input.screen, + ...(accountId ? { accountId } : {}), + ...(input.from ? { from: input.from } : {}), + ...(input.to ? { to: input.to } : {}), + ...(input.instrumentUid + ? { instrumentUid: input.instrumentUid.toLowerCase() } + : {}), + ...(query ? { query } : {}), + ...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}), + ...(input.candleRange + ? { + candleRange: { + from: new Date(input.candleRange.from).toISOString(), + to: new Date(input.candleRange.to).toISOString(), + }, + } + : {}), + ...(input.categories + ? { categories: [...new Set(input.categories)].sort() } + : {}), + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }; +} diff --git a/src/modules/investments/tbank-sync/tbank-sync.controller.ts b/src/modules/investments/tbank-sync/tbank-sync.controller.ts new file mode 100644 index 0000000..faaabe3 --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-sync.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + BadRequestException, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Req, + UseGuards, + ValidationPipe, + type ValidationError, +} from '@nestjs/common'; +import { + ApiAcceptedResponse, + ApiBearerAuth, + ApiBody, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; +import type { Request } from 'express'; +import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; +import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; +import { + InvestmentScreenDto, + TbankSyncAcceptedDto, + TbankSyncStatusDto, +} from './tbank-sync.dto'; +import { TbankSyncService } from './tbank-sync.service'; + +@ApiTags('tbank-sync') +@ApiProblemResponses() +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('tbank-sync') +export class TbankSyncController { + constructor(private readonly sync: TbankSyncService) {} + @Post() + @HttpCode(HttpStatus.ACCEPTED) + @ApiBody({ type: InvestmentScreenDto }) + @ApiAcceptedResponse({ type: TbankSyncAcceptedDto }) + request( + @Req() request: Request & { user: AccessPayload }, + @Body( + new ValidationPipe({ + expectedType: InvestmentScreenDto, + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + exceptionFactory: (message) => + new BadRequestException({ message: descriptorErrors(message) }), + }), + ) + input: unknown, + ) { + return this.sync.request(request.user.sub, input as InvestmentScreenDto); + } + @Get(':id') + @ApiOkResponse({ type: TbankSyncStatusDto }) + status( + @Req() request: Request & { user: AccessPayload }, + @Param('id') id: string, + ) { + return this.sync.status(request.user.sub, id); + } +} + +function descriptorErrors( + errors: ValidationError[], + prefix = '', +): ValidationError[] { + return errors.flatMap((error) => [ + { ...error, property: `${prefix}${error.property}` }, + ...descriptorErrors(error.children ?? [], `${prefix}${error.property}/`), + ]); +} diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts new file mode 100644 index 0000000..4e2e8b5 --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -0,0 +1,112 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; +import { InvestmentResourceMetaDto } from '../investment-resource.dto'; +import { operationCategories } from '../operations/operations.api'; + +const maxAccountIdLength = 256; +const maxQueryLength = 80; +const maxPageSize = 1000; + +export const investmentScreens = [ + 'accounts', + 'account', + 'operations', + 'income', + 'events', + 'stocks', + 'bonds', + 'stock', + 'bond', +] as const; +export class SyncCandleRangeDto { + @ApiProperty({ format: 'date-time' }) @IsDateString() declare from: string; + @ApiProperty({ format: 'date-time' }) @IsDateString() declare to: string; +} +export class InvestmentScreenDto { + @ApiProperty({ enum: investmentScreens }) + @IsIn(investmentScreens) + declare screen: (typeof investmentScreens)[number]; + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(maxAccountIdLength) + accountId?: string; + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString({ strict: true }) + from?: string; + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString({ strict: true }) + to?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + instrumentUid?: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(maxQueryLength) + query?: string; + @ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] }) + @IsOptional() + @IsIn(['month', 'halfYear', 'year', 'all']) + candlePeriod?: 'month' | 'halfYear' | 'year' | 'all'; + @ApiPropertyOptional({ type: SyncCandleRangeDto }) + @IsOptional() + @ValidateNested() + @Type(() => SyncCandleRangeDto) + candleRange?: SyncCandleRangeDto; + @ApiPropertyOptional({ enum: operationCategories, isArray: true }) + @IsOptional() + @IsArray() + @IsIn(operationCategories, { each: true }) + categories?: (typeof operationCategories)[number][]; + @ApiPropertyOptional({ minimum: 1, maximum: maxPageSize }) + @IsOptional() + @IsInt() + @Min(1) + @Max(maxPageSize) + limit?: number; +} +export class TbankSyncAcceptedDto { + @ApiProperty({ format: 'uuid' }) declare id: string; + @ApiProperty({ type: InvestmentScreenDto }) + declare scope: InvestmentScreenDto; + @ApiProperty({ format: 'date-time' }) declare now: string; +} +export class TbankSyncProgressDto { + @ApiProperty() declare completed: number; + @ApiProperty() declare total: number; + @ApiProperty() declare discovering: boolean; +} +export class TbankSyncStatusDto { + @ApiProperty({ format: 'uuid' }) declare id: string; + @ApiProperty({ type: InvestmentScreenDto, nullable: true }) + declare scope: InvestmentScreenDto | null; + @ApiProperty({ format: 'date-time' }) declare now: string; + @ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] }) + declare state: 'running' | 'succeeded' | 'failed' | 'cancelled'; + @ApiProperty({ type: String, nullable: true }) declare errorCode: + string | null; + @ApiProperty({ type: TbankSyncProgressDto }) + declare progress: TbankSyncProgressDto; + @ApiProperty({ type: InvestmentResourceMetaDto, isArray: true }) + declare resources: InvestmentResourceMetaDto[]; +} diff --git a/src/modules/investments/tbank-sync/tbank-sync.module.ts b/src/modules/investments/tbank-sync/tbank-sync.module.ts new file mode 100644 index 0000000..b963c81 --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-sync.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { TbankModule } from '../../../integrations/tbank/tbank.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; +import { OperationsModule } from '../operations/operations.module'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { InstrumentsModule } from '../instruments/instruments.module'; +import { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module'; +import { IncomeModule } from '../income/income.module'; +import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module'; +import { TbankSyncController } from './tbank-sync.controller'; +import { TbankSyncService } from './tbank-sync.service'; +import { TbankSyncRepository } from './tbank-sync.repository'; +@Module({ + imports: [ + AuthModule, + TbankModule, + PortfolioModule, + OperationsModule, + InstrumentSourcesModule, + InstrumentsModule, + BrokerageAccountsModule, + IncomeModule, + UpcomingEventsModule, + ], + controllers: [TbankSyncController], + providers: [TbankSyncService, TbankSyncRepository], +}) +export class TbankSyncModule {} diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts new file mode 100644 index 0000000..d5b53bc --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { createHash, randomUUID } from 'node:crypto'; +import type { Prisma } from '../../../../generated/prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; +import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service'; +import type { InvestmentScreenDto } from './tbank-sync.dto'; +export type ScreenIntent = { scope: InvestmentScreenDto; now: string }; +export type ScreenProgress = { + children: string[]; + attempts?: Record; + dispatched: boolean; +}; +@Injectable() +export class TbankSyncRepository { + constructor(private readonly prisma: PrismaService) {} + reserve( + userId: string, + revision: string, + scope: InvestmentScreenDto, + runId: string, + ) { + return this.prisma.$transaction(async (tx) => { + const valid = await tx.tbankConnection.updateMany({ + where: { userId, revision, invalidToken: false }, + data: { membershipRevision: { increment: 0 } }, + }); + if (!valid.count) return null; + const scopeKey = `screen:${createHash('sha256').update(JSON.stringify(scope)).digest('hex')}`; + const key = { userId, revision, scopeKey }; + const previous = await tx.tbankSyncWork.findUnique({ + where: { userId_revision_scopeKey: key }, + }); + if (previous?.state === 'running' && previous.processRunId === runId) { + const progress = previous.progress as ScreenProgress; + const children = progress.children ?? []; + if ( + !progress.dispatched || + (await tx.tbankSyncWork.count({ + where: { userId, revision, id: { in: children }, state: 'running' }, + })) + ) + return { work: previous, launch: false }; + } + const data = { + attemptId: randomUUID(), + processRunId: runId, + state: 'running', + priority: 0, + intent: JSON.parse( + JSON.stringify({ scope, now: new Date().toISOString() }), + ) as Prisma.InputJsonObject, + progress: { children: [], dispatched: false }, + errorCode: null, + }; + const work = await tx.tbankSyncWork.upsert({ + where: { userId_revision_scopeKey: key }, + create: { ...key, ...data, id: randomUUID() }, + update: { ...data, id: randomUUID() }, + }); + return { work, launch: true }; + }); + } + read(userId: string, revision: string, id: string) { + return this.prisma.$transaction(async (tx) => { + const work = await tx.tbankSyncWork.findFirst({ + where: { userId, revision, id }, + }); + if (!work) return null; + const progress = work.progress as ScreenProgress; + const children = await tx.tbankSyncWork.findMany({ + where: { userId, revision, id: { in: progress.children ?? [] } }, + select: { id: true, attemptId: true, state: true, errorCode: true }, + }); + return { + work, + children: children.map((child) => + progress.attempts?.[child.id] && + progress.attempts[child.id] !== child.attemptId + ? { ...child, state: 'cancelled', errorCode: null } + : child, + ), + progress, + }; + }); + } + async progress( + attempt: TbankSyncAttempt, + children: string[], + dispatched: boolean, + errorCode: string | null = null, + ) { + return this.prisma.$transaction(async (tx) => { + const previous = await tx.tbankSyncWork.findFirst({ where: attempt }); + if (!previous) return { count: 0 }; + const old = previous.progress as ScreenProgress; + const rows = await tx.tbankSyncWork.findMany({ + where: { + userId: attempt.userId, + revision: attempt.revision, + id: { in: children }, + }, + select: { id: true, attemptId: true }, + }); + const attempts = { + ...Object.fromEntries(rows.map((row) => [row.id, row.attemptId])), + ...old.attempts, + }; + return tx.tbankSyncWork.updateMany({ + where: { + ...attempt, + state: 'running', + connection: { invalidToken: false }, + }, + data: { + progress: { children: [...new Set(children)], attempts, dispatched }, + ...(errorCode ? { state: 'failed', errorCode } : {}), + }, + }); + }); + } +} diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts new file mode 100644 index 0000000..99788d1 --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -0,0 +1,595 @@ +import { + Injectable, + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { + TbankAccessService, + TbankMembershipService, + InstrumentMetadataService, +} from '../../../integrations/tbank/tbank.api'; +import { PortfolioService } from '../portfolio/portfolio.api'; +import { + BrokerageOperationsService, + BrokerageOperationsSyncService, + type OperationInterval, +} from '../operations/operations.api'; +import { + CandleService, + CatalogDiscoveryService, + CatalogEnrichmentService, +} from '../instruments/instruments.api'; +import { + StockIncomeService, + BondIncomeService, +} from '../instruments/instrument-income.api'; +import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api'; +import { IncomeAnalyticsService } from '../income/income-read.api'; +import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; +import { planInvestmentScreen } from '../investment-sync-plan'; +import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; +import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service'; +import { + TbankSyncRepository, + type ScreenIntent, +} from './tbank-sync.repository'; +import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; +import { normalizeScreen } from './tbank-screen'; +import { investmentResourceMeta } from '../investment-resource.mapper'; + +@Injectable() +export class TbankSyncService { + private readonly runId = randomUUID(); + private readonly startedAt = new Date(); + private reservations: Promise = Promise.resolve(); + constructor( + private readonly repository: TbankSyncRepository, + private readonly access: TbankAccessService, + private readonly membership: TbankMembershipService, + private readonly portfolio: PortfolioService, + private readonly operations: BrokerageOperationsSyncService, + private readonly ledger: BrokerageOperationsService, + private readonly metadata: InstrumentMetadataService, + private readonly discovery: CatalogDiscoveryService, + private readonly sources: CatalogEnrichmentService, + private readonly candles: CandleService, + private readonly accounts: BrokerageAccountsService, + private readonly income: IncomeAnalyticsService, + private readonly events: UpcomingEventsService, + private readonly stockIncome: StockIncomeService, + private readonly bondIncome: BondIncomeService, + ) {} + + async request(userId: string, input: InvestmentScreenDto) { + const scope = normalizeScreen(input); + const connection = await this.access.local(userId, scope.accountId); + const reservation = this.reservations.then(() => + this.repository.reserve(userId, connection.revision, scope, this.runId), + ); + this.reservations = reservation.catch(() => undefined); + const reserved = await reservation; + if (!reserved) throw new ServiceUnavailableException(); + const intent = reserved.work.intent as unknown as ScreenIntent; + if (reserved.launch) { + const attempt = { + userId, + revision: connection.revision, + id: reserved.work.id, + attemptId: reserved.work.attemptId, + }; + setImmediate(() => { + void this.dispatch(attempt, intent); + }); + } + return { id: reserved.work.id, ...intent }; + } + + private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) { + const children: string[] = []; + const add = async (ids: (string | null | undefined)[]) => { + children.push(...ids.filter((id): id is string => Boolean(id))); + await this.repository.progress(attempt, children, false); + }; + try { + const { userId, revision } = attempt; + const { scope } = intent; + const now = new Date(intent.now); + const member = await this.membership.request(userId, revision, true); + await add([member?.id]); + await member?.done; + const current = await this.access.local(userId, scope.accountId); + if (current.revision !== revision) return; + const accountScope = scope.accountId ?? 'all'; + const selectedAccounts = current.accounts.filter( + (account) => + accountScope === 'all' || account.accountId === accountScope, + ); + if ( + ['accounts', 'account', 'events', 'stock', 'bond'].includes( + scope.screen, + ) + ) { + for (const account of scope.screen === 'stock' || + scope.screen === 'bond' + ? current.accounts + : selectedAccounts) { + const work = await this.portfolio.request( + userId, + revision, + account.accountId, + true, + ); + await add([work?.id]); + await work?.done; + } + } + const saved = await this.portfolio.read( + userId, + scope.screen === 'stock' || scope.screen === 'bond' + ? 'all' + : accountScope, + true, + ); + const positions = saved.data?.positions ?? []; + const instruments = [ + ...new Map( + positions.flatMap((position) => + position.instrumentUid && + (position.type === 'STOCK' || position.type === 'BOND') + ? [ + [ + position.instrumentUid, + { + uid: position.instrumentUid, + type: position.type, + historyFrom: null, + }, + ] as const, + ] + : [], + ), + ).values(), + ]; + const plan = planInvestmentScreen( + { + ...scope, + candleRange: scope.candleRange + ? { + from: new Date(scope.candleRange.from), + to: new Date(scope.candleRange.to), + } + : undefined, + }, + { + accounts: selectedAccounts, + instruments: + scope.screen === 'stocks' || scope.screen === 'bonds' + ? [] + : instruments, + membershipKnown: Boolean(current.membershipAt), + }, + now, + true, + ); + const intervals: OperationInterval[] = plan.sources.flatMap((source) => + source.kind === 'operations' && + source.accountId && + source.from && + source.to + ? [ + { + accountId: source.accountId, + from: source.from, + to: source.to, + ...(source.instrumentUid + ? { instrumentUid: source.instrumentUid } + : {}), + }, + ] + : [], + ); + if (intervals.length) + await add([ + await this.operations.request( + userId, + intervals, + true, + 'screen', + revision, + ), + ]); + for (const source of plan.sources) { + const uid = source.instrumentUid; + const type = source.instrumentType === 'BOND' ? 'bond' : 'share'; + if (source.kind === 'catalog') + await add( + await this.discovery.refresh(userId, revision, { + instrumentType: type, + query: source.query ?? '', + }), + ); + if (!uid) continue; + if (source.kind === 'metadata') + await add([ + await this.metadata.request(userId, revision, { + instrumentUid: uid, + }), + ]); + if (source.kind === 'quote') + await add( + await this.sources.requestMany( + userId, + revision, + type, + [uid], + true, + [ + type === 'bond' ? 'bond-current' : 'detail', + 'last-price', + 'trading-status', + 'comparison', + ], + ), + ); + if (source.kind === 'company') + await add( + await this.sources.requestMany( + userId, + revision, + type, + [uid], + true, + ['description', 'fundamentals'], + ), + ); + if (source.kind === 'metrics') + await add( + await this.sources.requestMany( + userId, + revision, + type, + [uid], + true, + ['coupons', 'yield', 'rating'], + ), + ); + if (source.kind === 'calendar' && source.from && source.to) + await add( + await this.sources.requestMany( + userId, + revision, + type, + [uid], + true, + [ + type === 'share' + ? 'dividends' + : scope.screen === 'bond' + ? 'coupons' + : 'bond-events', + ], + { + from: source.from.toISOString().slice(0, 10), + to: source.to.toISOString().slice(0, 10), + ticker: + positions.find((position) => position.instrumentUid === uid) + ?.ticker ?? undefined, + }, + ), + ); + } + if ( + (scope.screen === 'stock' || scope.screen === 'bond') && + scope.instrumentUid + ) { + await add([ + await this.candles.request( + userId, + revision, + { + uid: scope.instrumentUid, + instrumentType: scope.screen === 'bond' ? 'bond' : 'share', + period: scope.candlePeriod ?? 'month', + ...(scope.candleRange + ? { + range: { + from: new Date(scope.candleRange.from), + to: new Date(scope.candleRange.to), + }, + } + : {}), + }, + now, + true, + ), + ]); + } + await this.repository.progress(attempt, children, true); + } catch { + await this.repository + .progress(attempt, children, true, 'TBANK_UNAVAILABLE') + .catch(() => undefined); + } + } + + async status(userId: string, id: string): Promise { + const connection = await this.access + .local(userId) + .catch((error: unknown) => { + if (error instanceof ConflictException) throw new NotFoundException(); + throw error; + }); + const saved = await this.repository.read(userId, connection.revision, id); + if (!saved) throw new NotFoundException(); + if (!saved.work.scopeKey.startsWith('screen:')) + return this.sourceStatus(userId, saved.work); + const intent = saved.work.intent as unknown as ScreenIntent; + await this.access.local(userId, intent.scope.accountId); + const running = saved.children.some((child) => child.state === 'running'); + const failed = + saved.work.errorCode ?? + saved.children.find((child) => child.errorCode)?.errorCode ?? + null; + const state = + saved.work.processRunId !== this.runId && + (!saved.progress.dispatched || running) + ? 'cancelled' + : !saved.progress.dispatched || running + ? 'running' + : failed + ? 'failed' + : saved.children.some((child) => child.state === 'cancelled') + ? 'cancelled' + : 'succeeded'; + const resources = await this.observe(userId, intent.scope); + if ( + (await this.access.local(userId, intent.scope.accountId)).revision !== + connection.revision + ) + throw new NotFoundException(); + return { + id, + ...intent, + state, + errorCode: failed, + progress: { + completed: saved.children.filter((child) => child.state !== 'running') + .length, + total: saved.children.length, + discovering: !saved.progress.dispatched, + }, + resources, + }; + } + + private async sourceStatus( + userId: string, + work: NonNullable>>['work'], + ): Promise { + const resources: InvestmentResourceMetaDto[] = []; + const input = work.intent as Record; + const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => { + resources.push((await read).meta); + }; + if (work.scopeKey === 'membership') + await add(this.accounts.list(userId, true)); + else if ( + input.source === 'portfolio' && + typeof input.accountId === 'string' + ) + await add(this.portfolio.read(userId, input.accountId, true)); + else if (input.source === 'catalog') { + const type = input.instrumentType === 'bond' ? 'bond' : 'share'; + if (typeof input.query === 'string') + await add( + this.discovery.search( + userId, + { + instrumentType: type, + query: input.query, + exactTicker: input.exactTicker === true, + }, + true, + ), + ); + else if ( + typeof input.uid === 'string' && + typeof input.kind === 'string' + ) { + const range = + typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') + ? (JSON.parse(input.rangeKey) as string[]) + : null; + const sources = await this.sources.readMany( + userId, + type, + [input.uid], + true, + [ + input.kind as Parameters< + CatalogEnrichmentService['readMany'] + >[4] extends (infer K)[] | undefined + ? K + : never, + ], + range ? { from: range[0], to: range[1] } : undefined, + ); + resources.push(...sources.map((source) => source.meta)); + } + } else if (input.source === 'metadata') { + const identity = { + instrumentUid: + typeof input.instrumentUid === 'string' ? input.instrumentUid : null, + figi: typeof input.figi === 'string' ? input.figi : null, + }; + const source = await this.metadata.read(userId, identity, true); + resources.push( + investmentResourceMeta({ + resourceKey: work.scopeKey, + dataRevision: source?.dataRevision ?? '0', + availability: source?.complete ? 'complete' : 'missing', + fetchedAt: source?.fetchedAt ?? null, + freshForMs: Number.POSITIVE_INFINITY, + work, + }), + ); + } else if ( + work.scopeKey.startsWith('candles:') && + typeof input.uid === 'string' + ) { + await add( + this.candles.read( + userId, + { + uid: input.uid, + instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share', + period: input.period as 'month' | 'halfYear' | 'year' | 'all', + }, + true, + ), + ); + } else if ( + work.scopeKey.startsWith('operations:') && + Array.isArray(input.intervals) + ) { + for (const key of input.intervals as string[]) { + const [accountId, from, end, instrumentUid] = JSON.parse( + key, + ) as string[]; + await add( + this.ledger.read(userId, accountId, { + from, + to: new Date(Date.parse(end) - 1).toISOString(), + ...(instrumentUid ? { instrumentUid } : {}), + sync: 'observe', + }), + ); + } + } + const observed = work.scopeKey.startsWith('operations:') + ? await this.operations.observe(userId, work.id) + : work; + const state = + observed.state === 'running' + ? observed.updatedAt < this.startedAt + ? 'cancelled' + : 'running' + : observed.state === 'failed' + ? 'failed' + : observed.state === 'cancelled' + ? 'cancelled' + : 'succeeded'; + if ((await this.access.local(userId)).revision !== work.revision) + throw new NotFoundException(); + return { + id: work.id, + scope: null, + now: work.createdAt.toISOString(), + state, + errorCode: observed.errorCode, + progress: { + completed: state === 'running' ? 0 : 1, + total: 1, + discovering: false, + }, + resources, + }; + } + + private async observe(userId: string, scope: InvestmentScreenDto) { + const resources: InvestmentResourceMetaDto[] = []; + const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => { + resources.push((await read).meta); + }; + const account = scope.accountId ?? 'all'; + if (scope.screen === 'accounts') + await add(this.accounts.list(userId, true)); + if (scope.screen === 'account') { + await add(this.portfolio.read(userId, account, true)); + await add(this.income.summary(userId, account, true)); + await add(this.events.get(userId, account, { sync: 'observe' })); + } + if (['account', 'operations', 'stock', 'bond'].includes(scope.screen)) + await add( + this.ledger.read(userId, account, { + from: scope.from, + to: scope.to, + instrumentUid: scope.instrumentUid, + categories: scope.categories, + limit: scope.limit, + sync: 'observe', + }), + ); + if (scope.screen === 'income') + await add( + this.income.analytics(userId, account, scope.from, scope.to, true), + ); + if (scope.screen === 'events') + await add( + this.events.get(userId, account, { + from: scope.from, + to: scope.to, + sync: 'observe', + }), + ); + const type = + scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share'; + if (scope.screen === 'stocks' || scope.screen === 'bonds') + await add( + this.discovery.search( + userId, + { instrumentType: type, query: scope.query ?? '' }, + true, + ), + ); + if ( + (scope.screen === 'stock' || scope.screen === 'bond') && + scope.instrumentUid + ) { + const uid = scope.instrumentUid; + await add(this.discovery.detail(userId, type, uid, true)); + await add(this.sources.section(userId, type, uid, 'quote', true)); + await add( + this.sources.section( + userId, + type, + uid, + type === 'share' ? 'company' : 'metrics', + true, + ), + ); + await add( + this.candles.read( + userId, + { + uid, + instrumentType: type, + period: scope.candlePeriod ?? 'month', + ...(scope.candleRange + ? { + range: { + from: new Date(scope.candleRange.from), + to: new Date(scope.candleRange.to), + }, + } + : {}), + }, + true, + ), + ); + await add( + type === 'share' + ? this.stockIncome.readPosition(userId, uid, true) + : this.bondIncome.readPosition(userId, uid, true), + ); + await add( + type === 'share' + ? this.stockIncome.dividends(userId, uid, true) + : this.bondIncome.coupons(userId, uid, true), + ); + } + return resources; + } +} diff --git a/src/modules/investments/upcoming-events/upcoming-events-read.api.ts b/src/modules/investments/upcoming-events/upcoming-events-read.api.ts new file mode 100644 index 0000000..50d6466 --- /dev/null +++ b/src/modules/investments/upcoming-events/upcoming-events-read.api.ts @@ -0,0 +1 @@ +export { UpcomingEventsService } from './upcoming-events.service'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.controller.ts b/src/modules/investments/upcoming-events/upcoming-events.controller.ts index dfc1f04..2a5d4e4 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.controller.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.controller.ts @@ -4,7 +4,7 @@ import type { Request } from 'express'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { - UpcomingEventsDto, + UpcomingEventsResourceDto, UpcomingEventsQueryDto, } from './upcoming-events.dto'; import { UpcomingEventsService } from './upcoming-events.service'; @@ -18,7 +18,7 @@ export class UpcomingEventsController { constructor(private readonly events: UpcomingEventsService) {} @Get('all/events') - @ApiOkResponse({ type: UpcomingEventsDto }) + @ApiOkResponse({ type: UpcomingEventsResourceDto }) all( @Req() request: Request & { user: AccessPayload }, @Query() query: UpcomingEventsQueryDto, @@ -27,7 +27,7 @@ export class UpcomingEventsController { } @Get(':accountId/events') - @ApiOkResponse({ type: UpcomingEventsDto }) + @ApiOkResponse({ type: UpcomingEventsResourceDto }) get( @Req() request: Request & { user: AccessPayload }, @Param('accountId') accountId: string, diff --git a/src/modules/investments/upcoming-events/upcoming-events.dto.ts b/src/modules/investments/upcoming-events/upcoming-events.dto.ts index be23797..402a37a 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.dto.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.dto.ts @@ -1,9 +1,13 @@ +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsDateString, IsOptional, Matches } from 'class-validator'; import { MoneyDto } from '../../../common/money.dto'; import { AccountReferenceDto } from '../account-reference.dto'; -export class UpcomingEventsQueryDto { +export class UpcomingEventsQueryDto extends InvestmentReadQueryDto { @ApiPropertyOptional({ format: 'date', description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.', @@ -72,3 +76,10 @@ export class UpcomingEventsDto { @ApiProperty({ type: MoneyDto, isArray: true }) declare incomeTotalsByCurrency: MoneyDto[]; } + +export class UpcomingEventsResourceDto { + @ApiProperty({ type: UpcomingEventsDto, nullable: true }) + declare data: UpcomingEventsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) + declare meta: InvestmentResourceMetaDto; +} diff --git a/src/modules/investments/upcoming-events/upcoming-events.module.ts b/src/modules/investments/upcoming-events/upcoming-events.module.ts index b2cbdd4..fbd2ad2 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.module.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.module.ts @@ -1,13 +1,15 @@ +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { Module } from '@nestjs/common'; -import { MoexModule } from '../../../integrations/moex/moex.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { UpcomingEventsController } from './upcoming-events.controller'; import { UpcomingEventsService } from './upcoming-events.service'; @Module({ - imports: [AuthModule, TbankModule, MoexModule], + imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule], controllers: [UpcomingEventsController], providers: [UpcomingEventsService], + exports: [UpcomingEventsService], }) export class UpcomingEventsModule {} diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts index 6f8f9a7..762017f 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts @@ -1,434 +1,54 @@ -import { - BadRequestException, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; - -jest.mock('../../../integrations/tbank/tbank-access.service', () => ({ - TbankAccessService: class TbankAccessService {}, +import { dividendEvent, bondEvent } from './upcoming-events.service'; +import { decimalFromString } from '../../../common/decimal'; +jest.mock('../../../prisma/prisma.service', () => ({ + PrismaService: class {}, })); - -import type { TbankAccountContext } from '../../../integrations/tbank/tbank-access.service'; -import { UpcomingEventsService } from './upcoming-events.service'; - -describe('UpcomingEventsService', () => { - const access = { withAccount: jest.fn() }; - const tbank = { - getPortfolio: jest.fn(), - getInstrumentByUid: jest.fn(), - getInstrumentByFigi: jest.fn(), - getDividends: jest.fn(), - }; - const moex = { bondEvents: jest.fn() }; - const requestContext = { set: jest.fn() }; - const logger = { info: jest.fn(), event: jest.fn() }; - let service: UpcomingEventsService; - - beforeEach(() => { - service = new UpcomingEventsService( - access as never, - tbank as never, - moex as never, - requestContext as never, - logger as never, - ); - jest.clearAllMocks(); - access.withAccount.mockImplementation( - ( - _userId: string, - _accountId: string, - handler: (context: TbankAccountContext) => Promise, - ) => - handler({ - token: 'token', - generation: 'generation-1', - account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' }, - }), - ); - tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' }); - moex.bondEvents.mockResolvedValue([]); - tbank.getDividends.mockResolvedValue([]); - }); - - it('includes both selected boundaries beyond the default window and isolates dividend caches by period', async () => { - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'S', - ticker: 'SHARE', - instrumentType: 'share', - quantity: { units: 2 }, - }, - { - figi: 'B', - ticker: 'BOND', - instrumentType: 'bond', - quantity: { units: 3 }, - }, - ], - }); - moex.bondEvents.mockResolvedValue([ - { type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' }, - { type: 'offer', date: '2099-02-28' }, - { type: 'maturity', date: '2099-03-01', value: '1000', currency: 'RUB' }, - { type: 'offer', date: '2099-01-31' }, - ]); - tbank.getDividends.mockImplementation( - (_token: string, _id: string, from: string) => - Promise.resolve([ - { - dividendNet: { units: 5, currency: 'RUB' }, - recordDate: { - seconds: String(Date.parse(`${from}T00:00:00Z`) / 1000), - }, - }, - ]), - ); - const february = { from: '2099-02-01', to: '2099-02-28' }; - const first = await service.get('user', 'account', february); - expect(first.events.map(({ type, date }) => ({ type, date }))).toEqual([ - { type: 'coupon', date: february.from }, - { type: 'dividend', date: february.from }, - { type: 'offer', date: february.to }, - ]); - expect(first.incomeTotalsByCurrency).toEqual([ - { currency: 'RUB', value: '16' }, - ]); - const march = await service.get('user', 'account', { - from: '2099-03-01', - to: '2099-03-01', - }); - expect(march.events.map((event) => event.type)).toEqual([ - 'dividend', - 'maturity', - ]); - expect(march.incomeTotalsByCurrency).toEqual([ - { currency: 'RUB', value: '10' }, - ]); - await service.get('user', 'account', february); - expect(tbank.getDividends).toHaveBeenCalledTimes(2); - expect(tbank.getDividends).toHaveBeenNthCalledWith( - 1, - 'token', - 'S', - february.from, - february.to, - ); - expect(tbank.getDividends).toHaveBeenNthCalledWith( - 2, - 'token', - 'S', - '2099-03-01', - '2099-03-01', - ); - }); - - it('defaults the end to 30 days after the selected start', async () => { - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'B', - ticker: 'BOND', - instrumentType: 'bond', - quantity: { units: 1 }, - }, - ], - }); - moex.bondEvents.mockResolvedValue([ - { type: 'offer', date: '2099-01-01' }, - { type: 'offer', date: '2099-01-31' }, - { type: 'offer', date: '2099-02-01' }, - ]); - const result = await service.get('user', 'account', { from: '2099-01-01' }); - expect(result.events.map((event) => event.date)).toEqual([ - '2099-01-01', - '2099-01-31', - ]); - }); - - it('projects exact amounts and preserves registry dates and offers', async () => { - const now = new Date(); - const day = (offset: number) => - new Date( - Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - now.getUTCDate() + offset, - ), - ) - .toISOString() - .slice(0, 10); - tbank.getInstrumentByFigi.mockResolvedValue({ - name: 'Бумага', - isin: ' RU000A0JX0J2 ', - brand: { logoName: ' instrument-logo.png ' }, - }); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'S', - ticker: 'SBER', - instrumentType: 'share', - quantity: { units: 2 }, - }, - { - figi: 'B', - ticker: 'BOND', - instrumentType: 'bond', - quantity: { units: 3 }, - }, - ], - }); - tbank.getDividends.mockResolvedValue([ - { - dividendNet: { units: 1, nano: 250000000, currency: 'RUB' }, - recordDate: { - seconds: String(Math.floor(Date.parse(`${day(1)}T00:00:00Z`) / 1000)), - }, - dividendType: 'Regular Cash', - }, - ]); - moex.bondEvents.mockResolvedValue([ - { type: 'offer', date: day(2) }, - { type: 'coupon', date: day(1), value: '2.5', currency: 'RUB' }, - { type: 'maturity', date: day(31), value: '1000', currency: 'RUB' }, - ]); - - await expect(service.get('user', 'account')).resolves.toMatchObject({ - events: [ - { - type: 'coupon', - projectedAmount: { currency: 'RUB', value: '7.5' }, - dateKind: 'event', - isin: 'RU000A0JX0J2', - logoName: 'instrument-logo.png', - }, - { - type: 'dividend', - projectedAmount: { currency: 'RUB', value: '2.5' }, - dateKind: 'registry', - isin: 'RU000A0JX0J2', - logoName: 'instrument-logo.png', - }, - { - type: 'offer', - projectedAmount: null, - isin: 'RU000A0JX0J2', - logoName: 'instrument-logo.png', - }, - ], +const metadata = { + figi: 'figi', + instrumentUid: 'uid', + ticker: 'TEST', + displayName: 'Synthetic', + isin: null, + logoName: null, +}; +describe('forecast financial projections', () => { + it('preserves registry dates, source currency and exact signed fractional quantities', () => { + expect( + dividendEvent(metadata, decimalFromString('-2.5'), { + recordDate: { seconds: String(Date.parse('2026-10-13Z') / 1000) }, + dividendNet: { currency: 'usd', units: '10', nano: 1 }, + }), + ).toMatchObject({ + date: '2026-10-13', + dateKind: 'registry', + projectedAmount: { currency: 'USD', value: '-25.0000000025' }, }); }); - - it.each([undefined, ' '])( - 'keeps events without optional metadata when it is %s', - async (metadata) => { - const today = new Date().toISOString().slice(0, 10); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - instrumentUid: 'bond-uid', - ticker: 'BOND', - instrumentType: 'bond', - quantity: { units: 1 }, - }, - ], - }); - tbank.getInstrumentByUid.mockResolvedValue({ - name: 'Bond', - isin: metadata, - brand: { logoName: metadata }, - }); - moex.bondEvents.mockResolvedValue([{ type: 'offer', date: today }]); - await expect(service.get('user', 'account')).resolves.toMatchObject({ - events: [{ type: 'offer', isin: null, logoName: null }], - }); - }, - ); - - it('does not read upstream data for inaccessible accounts', async () => { - access.withAccount.mockRejectedValueOnce(new NotFoundException()); - await expect(service.get('user', 'account')).rejects.toEqual( - expect.any(NotFoundException), - ); - expect(tbank.getPortfolio).not.toHaveBeenCalled(); + it('does not invent an amount for offers or missing currency', () => { + expect( + bondEvent(metadata, decimalFromString('3'), { + type: 'offer', + date: '2026-10-01', + }).projectedAmount, + ).toBeNull(); + expect( + dividendEvent(metadata, decimalFromString('3'), { + recordDate: { seconds: '1790812800' }, + dividendNet: { units: '10' }, + }).projectedAmount, + ).toBeNull(); }); - - it('uses the portfolio ticker without an unnecessary instrument lookup', async () => { - const today = new Date().toISOString().slice(0, 10); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'S', - ticker: 'SBER', - instrumentType: 'share', - quantity: { units: 1 }, - }, - ], + it('preserves redemption as a non-income event with its exact amount', () => { + expect( + bondEvent(metadata, decimalFromString('2.5'), { + type: 'maturity', + date: '2026-10-01', + currency: 'rub', + value: '500.000000001', + }), + ).toMatchObject({ + type: 'maturity', + projectedAmount: { currency: 'RUB', value: '1250.0000000025' }, }); - tbank.getInstrumentByFigi.mockRejectedValue( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - tbank.getDividends.mockResolvedValue([ - { - dividendNet: { units: 1, nano: 0, currency: 'RUB' }, - recordDate: { - seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)), - }, - dividendType: 'Regular Cash', - }, - ]); - - await expect(service.get('user', 'account')).resolves.toMatchObject({ - events: [ - { - ticker: 'SBER', - projectedAmount: { currency: 'RUB', value: '1' }, - isin: null, - logoName: null, - }, - ], - }); - expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S'); - }); - - it('skips cancelled dividends', async () => { - const today = new Date().toISOString().slice(0, 10); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'S', - ticker: 'SBER', - instrumentType: 'share', - quantity: { units: 1 }, - }, - ], - }); - tbank.getDividends.mockResolvedValue([ - { - dividendNet: { units: 1, nano: 0, currency: 'RUB' }, - recordDate: { - seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)), - }, - dividendType: 'Cancelled', - }, - { - dividendNet: { units: 2, nano: 0, currency: 'RUB' }, - recordDate: { - seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)), - }, - dividendType: 'Regular Cash', - }, - ]); - - await expect(service.get('user', 'account')).resolves.toMatchObject({ - events: [ - { - type: 'dividend', - projectedAmount: { currency: 'RUB', value: '2' }, - }, - ], - }); - }); - - it('does not invent projected money when the source omits currency', async () => { - const today = new Date().toISOString().slice(0, 10); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'S', - ticker: 'SBER', - instrumentType: 'share', - quantity: { units: 1 }, - }, - ], - }); - tbank.getDividends.mockResolvedValue([ - { - dividendNet: { units: 1, nano: 0 }, - recordDate: { - seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)), - }, - dividendType: 'Regular Cash', - }, - ]); - - await expect(service.get('user', 'account')).resolves.toMatchObject({ - events: [{ type: 'dividend', projectedAmount: null }], - }); - }); - - it('adds exact source-currency income totals without changing the events list', async () => { - const today = new Date().toISOString().slice(0, 10); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'B', - ticker: 'BOND', - instrumentType: 'bond', - quantity: { units: 1 }, - }, - ], - }); - moex.bondEvents.mockResolvedValue([ - { type: 'coupon', date: today, value: '1.25', currency: 'RUB' }, - { type: 'maturity', date: today, value: '100', currency: 'RUB' }, - { type: 'offer', date: today }, - ]); - - const result = await service.get('user', 'account'); - expect(result.incomeTotalsByCurrency).toEqual([ - { currency: 'RUB', value: '1.25' }, - ]); - expect(result.events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: 'maturity' }), - expect.objectContaining({ type: 'offer' }), - ]), - ); - }); - - it('reuses upstream results within the cache TTL across requests', async () => { - const today = new Date().toISOString().slice(0, 10); - tbank.getPortfolio.mockResolvedValue({ - positions: [ - { - figi: 'S', - ticker: 'SBER', - instrumentType: 'share', - quantity: { units: 1 }, - }, - ], - }); - tbank.getDividends.mockResolvedValue([ - { - dividendNet: { units: 1, nano: 0, currency: 'RUB' }, - recordDate: { - seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)), - }, - dividendType: 'Regular Cash', - }, - ]); - - await service.get('user', 'account'); - await service.get('user', 'account'); - - expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1); - expect(tbank.getDividends).toHaveBeenCalledTimes(1); - }); - - it('preserves invalid token and source failures without a partial result', async () => { - access.withAccount.mockRejectedValueOnce( - new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }), - ); - await expect(service.get('user', 'account')).rejects.toEqual( - expect.any(BadRequestException), - ); - tbank.getPortfolio.mockRejectedValueOnce( - new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), - ); - await expect(service.get('user', 'account')).rejects.toEqual( - expect.any(ServiceUnavailableException), - ); }); }); diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index d169990..b1f2c36 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -1,5 +1,5 @@ -import { MILLISECONDS_PER_SECOND } from '../../../common/time'; import { BadRequestException, Injectable } from '@nestjs/common'; +import { MILLISECONDS_PER_SECOND } from '../../../common/time'; import { add, decimalFromString, @@ -7,285 +7,250 @@ import { isZero, multiply, } from '../../../common/decimal'; -import { RequestContextService } from '../../../common/logging/request-context.service'; -import { StructuredLogger } from '../../../common/logging/structured-logger.service'; import { MoneyDto } from '../../../common/money.dto'; -import { MoexBondEvent, MoexClient } from '../../../integrations/moex/moex.api'; +import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; import { decimal } from '../../../integrations/tbank/decimal'; - import { TbankAccessService, - TbankClient, - TbankDividend, - TbankInstrument, - TbankPortfolio, + InstrumentMetadataService, + metadataKey, + type TbankDividend, } from '../../../integrations/tbank/tbank.api'; +import { + PortfolioService, + ForecastService, + type PortfolioPositionDto, +} from '../portfolio/portfolio.api'; +import { CatalogEnrichmentService } from '../instruments/instruments.api'; +import { investmentResourceMeta } from '../investment-resource.mapper'; import { UpcomingEventDto, UpcomingEventsDto, UpcomingEventsQueryDto, } from './upcoming-events.dto'; - const DEFAULT_EVENT_WINDOW_DAYS = 30; - -type Position = NonNullable[number]; -type CachedEntry = { expiresAt: number; promise: Promise }; - -const positionsBatchSize = 5; -const dividendCacheTtlMs = 900_000; -const instrumentCacheTtlMs = 86_400_000; +const calendarReadBatchSize = 100; +type Position = PortfolioPositionDto; @Injectable() export class UpcomingEventsService { - private readonly dividendCache = new Map< - string, - CachedEntry - >(); - private readonly instrumentCache = new Map< - string, - CachedEntry - >(); - constructor( private readonly access: TbankAccessService, - private readonly tbank: TbankClient, - private readonly moex: MoexClient, - private readonly requestContext: RequestContextService, - private readonly logger: StructuredLogger, + private readonly portfolios: PortfolioService, + private readonly sources: CatalogEnrichmentService, + private readonly forecasts: ForecastService, + private readonly metadata: InstrumentMetadataService, ) {} async get( userId: string, accountId: string, query: UpcomingEventsQueryDto = {}, - ): Promise { + ) { const window = dateWindow(query); - this.requestContext.set({ userId, brokerageAccountId: accountId }); - return this.access.withAccount(userId, accountId, ({ token }) => - this.forAccount(token, accountId, window), - ); - } - - async getAll( - userId: string, - query: UpcomingEventsQueryDto = {}, - ): Promise { - const window = dateWindow(query); - return this.access.withSupportedAccounts( + const connection = await this.access.local(userId, accountId); + const portfolio = await this.portfolios.read( userId, - async ({ token, accounts }) => { - const events: (UpcomingEventDto & { - account: { id: string; name: string }; - })[] = []; - for (const account of accounts) { - const result = await this.forAccount(token, account.id, window); - events.push( - ...result.events.map((event) => ({ - ...event, - account: { id: account.id, name: account.name }, - })), - ); - } - events.sort( - (left, right) => - left.date.localeCompare(right.date) || - left.account.id.localeCompare(right.account.id), + accountId, + query.sync === 'observe', + ); + const sources: Awaited> = + []; + const events: UpcomingEventDto[] = []; + let ready = portfolio.data !== null; + const positions = portfolio.data?.positions.filter(supported) ?? []; + const metadata = await this.metadata.savedMany(positions); + for (const type of ['share', 'bond'] as const) { + const identities = positions + .filter( + (position) => position.type === (type === 'share' ? 'STOCK' : 'BOND'), + ) + .map((position) => ({ + uid: + position.instrumentUid || + metadata.get(metadataKey(position))?.data?.uid, + ticker: + position.ticker || + metadata.get(metadataKey(position))?.data?.ticker || + '', + })) + .filter((entry): entry is { uid: string; ticker: string } => + Boolean(entry.uid), ); - return { - accountId: 'all', + const uids = [...new Set(identities.map((entry) => entry.uid))]; + for ( + let offset = 0; + offset < uids.length; + offset += calendarReadBatchSize + ) + sources.push( + ...(await this.sources.readMany( + userId, + type, + uids.slice(offset, offset + calendarReadBatchSize), + query.sync === 'observe', + ['detail', type === 'share' ? 'dividends' : 'bond-events'], + { + ...window, + tickers: Object.fromEntries( + identities.map((entry) => [entry.uid, entry.ticker]), + ), + }, + )), + ); + } + for (const position of positions) { + const generic = metadata.get(metadataKey(position))?.data; + const uid = position.instrumentUid || generic?.uid; + if (!uid) { + ready = false; + sources.push({ + input: { uid: '', instrumentType: 'share', kind: 'detail' }, + version: null, + payload: undefined, + instrument: null, + price: null, + meta: investmentResourceMeta({ + resourceKey: `events/identity/${position.figi ?? ''}`, + dataRevision: '0', + availability: 'missing', + fetchedAt: null, + freshForMs: 0, + work: null, + }), + }); + continue; + } + const type = position.type === 'STOCK' ? 'share' : 'bond'; + const calendar = sources.find( + (source) => + source.input.uid === uid && + source.input.kind === + (type === 'share' ? 'dividends' : 'bond-events'), + ); + const instrument = + sources.find( + (source) => + source.input.uid === uid && source.input.kind === 'detail', + )?.instrument || generic; + const ticker = position.ticker || instrument?.ticker; + if (!calendar || calendar.meta.availability !== 'complete' || !ticker) { + ready = false; + continue; + } + const identity = { + figi: position.figi || null, + instrumentUid: uid, + ticker, + displayName: instrument?.name?.trim() || position.displayName || ticker, + isin: instrument?.isin || position.isin || null, + logoName: instrument?.brand?.logoName || position.logoName || null, + }; + const quantity = decimalFromString(position.quantity); + const projected = + type === 'share' + ? (calendar.payload?.dividends ?? []) + .filter( + (event) => + event.dividendType?.toLowerCase() !== 'cancelled' && + Boolean(event.recordDate), + ) + .map((event) => dividendEvent(identity, quantity, event)) + : (calendar.payload?.bondEvents ?? []).map((event) => + bondEvent(identity, quantity, event), + ); + events.push( + ...projected + .filter( + (event) => event.date >= window.from && event.date <= window.to, + ) + .map((event) => ({ + ...event, + ...(accountId === 'all' && position.account + ? { account: position.account } + : {}), + })), + ); + } + events.sort( + (a, b) => + a.date.localeCompare(b.date) || + (a.account?.id ?? '').localeCompare(b.account?.id ?? '') || + a.type.localeCompare(b.type) || + a.ticker.localeCompare(b.ticker), + ); + const candidate: UpcomingEventsDto | undefined = ready + ? { + accountId, calculatedAt: new Date().toISOString(), events, incomeTotalsByCurrency: incomeTotals(events), - }; - }, - ); - } - - private async forAccount( - token: string, - accountId: string, - window: { from: string; to: string }, - ): Promise { - const portfolio = await this.tbank.getPortfolio(token, accountId); - const supportedPositions = (portfolio.positions ?? []).filter(supported); - const events: UpcomingEventDto[][] = []; - for ( - let offset = 0; - offset < supportedPositions.length; - offset += positionsBatchSize - ) { - const batch = supportedPositions.slice( - offset, - offset + positionsBatchSize, - ); - events.push( - ...(await Promise.all( - batch.map((position) => this.positionEvents(token, position, window)), - )), - ); - } - const projected = events - .flat() - .filter((event) => event.date >= window.from && event.date <= window.to); - this.logger.info('upcoming-events.calculated', { - count: projected.length, + } + : undefined; + return this.forecasts.result({ + userId, + revision: connection.revision, + membershipRevision: connection.membershipRevision, + scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`, + portfolio: portfolio.meta, + sources, + candidate, }); - return { - accountId, - calculatedAt: new Date().toISOString(), - events: projected.sort( - (left, right) => - left.date.localeCompare(right.date) || - left.type.localeCompare(right.type) || - left.ticker.localeCompare(right.ticker), - ), - incomeTotalsByCurrency: incomeTotals(projected), - }; } - - private async positionEvents( - token: string, - position: Position, - window: { from: string; to: string }, - ): Promise { - const ticker = position.ticker?.trim(); - const instrument = ticker - ? await this.instrument(token, position).catch(() => { - this.logger.event('warn', 'upcoming-events.metadata-unavailable'); - return null; - }) - : await this.instrument(token, position); - const resolvedTicker = ticker || instrument?.ticker?.trim(); - if (!resolvedTicker) return []; - const metadata = { - figi: position.figi?.trim() || null, - instrumentUid: position.instrumentUid?.trim() || null, - ticker: resolvedTicker, - displayName: instrument?.name?.trim() || resolvedTicker, - isin: instrument?.isin?.trim() || null, - logoName: instrument?.brand?.logoName?.trim() || null, - }; - const quantity = decimal(position.quantity ?? {}); - if (position.instrumentType?.toLowerCase() === 'share') { - const dividends = await this.dividends(token, position, window); - this.logger.event('debug', 'upcoming-events.source-result', { - count: dividends.length, - }); - return dividends.map((event) => this.dividend(metadata, quantity, event)); - } - const events = await this.moex.bondEvents(resolvedTicker); - this.logger.event('debug', 'upcoming-events.source-result', { - count: events.length, - }); - return events.map((event) => this.bond(metadata, quantity, event)); + getAll(userId: string, query: UpcomingEventsQueryDto = {}) { + return this.get(userId, 'all', query); } +} - private async instrument(token: string, position: Position) { - const instrumentUid = position.instrumentUid?.trim(); - if (instrumentUid) - return this.cached( - this.instrumentCache, - instrumentUid, - instrumentCacheTtlMs, - () => this.tbank.getInstrumentByUid(token, instrumentUid), - ); - const figi = position.figi?.trim(); - if (figi) - return this.cached(this.instrumentCache, figi, instrumentCacheTtlMs, () => - this.tbank.getInstrumentByFigi(token, figi), - ); - return null; - } +export function dividendEvent( + metadata: Pick< + UpcomingEventDto, + 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' + >, + quantity: ReturnType, + event: TbankDividend, +): UpcomingEventDto { + return { + ...metadata, + type: 'dividend', + date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND) + .toISOString() + .slice(0, 10), + dateKind: 'registry', + projectedAmount: event.dividendNet + ? projectedMoney( + quantity, + formatDecimal(decimal(event.dividendNet)), + event.dividendNet.currency, + ) + : null, + }; +} - private async dividends( - token: string, - position: Position, - window: { from: string; to: string }, - ): Promise { - const instrumentId = position.instrumentUid ?? position.figi; - if (!instrumentId) return []; - const dividends = await this.cached( - this.dividendCache, - JSON.stringify([instrumentId, window.from, window.to]), - dividendCacheTtlMs, - () => - this.tbank.getDividends(token, instrumentId, window.from, window.to), - ); - return dividends.filter( - (event) => - event.dividendType !== 'Cancelled' && Boolean(event.recordDate), - ); - } - - private async cached( - cache: Map>, - key: string, - ttlMs: number, - load: () => Promise, - ): Promise { - const now = Date.now(); - const hit = cache.get(key); - if (hit && hit.expiresAt > now) return hit.promise; - const promise = load().catch((error: unknown) => { - cache.delete(key); - throw error; - }); - cache.set(key, { expiresAt: now + ttlMs, promise }); - return promise; - } - - private dividend( - metadata: Pick< - UpcomingEventDto, - 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' - >, - quantity: ReturnType, - event: TbankDividend, - ): UpcomingEventDto { - return { - ...metadata, - type: 'dividend', - date: new Date( - Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND, - ) - .toISOString() - .slice(0, 10), - dateKind: 'registry', - projectedAmount: event.dividendNet - ? projectedMoney( - quantity, - formatDecimal(decimal(event.dividendNet)), - event.dividendNet.currency, - ) +export function bondEvent( + metadata: Pick< + UpcomingEventDto, + 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' + >, + quantity: ReturnType, + event: MoexBondEvent, +): UpcomingEventDto { + return { + ...metadata, + type: event.type, + date: event.date, + dateKind: 'event', + projectedAmount: + 'value' in event + ? projectedMoney(quantity, event.value, event.currency) : null, - }; - } - - private bond( - metadata: Pick< - UpcomingEventDto, - 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' - >, - quantity: ReturnType, - event: MoexBondEvent, - ): UpcomingEventDto { - return { - ...metadata, - type: event.type, - date: event.date, - dateKind: 'event', - projectedAmount: - 'value' in event - ? projectedMoney(quantity, event.value, event.currency) - : null, - }; - } + }; } function supported(position: Position): boolean { return ( - !isZero(decimal(position.quantity ?? {})) && - ['share', 'bond'].includes(position.instrumentType?.toLowerCase() ?? '') + !isZero(decimalFromString(position.quantity)) && + ['STOCK', 'BOND'].includes(position.type) ); } diff --git a/test/all-accounts.e2e-spec.ts b/test/all-accounts.e2e-spec.ts index 55a2040..f6cef5b 100644 --- a/test/all-accounts.e2e-spec.ts +++ b/test/all-accounts.e2e-spec.ts @@ -1,15 +1,18 @@ -import { ConflictException, INestApplication } from '@nestjs/common'; +import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import type { Server } from 'node:http'; import request from 'supertest'; import { AppModule } from '../src/app.module'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { configureApp } from '../src/main'; describe('All accounts HTTP boundary', () => { let app: INestApplication; - const withSupportedAccounts = jest.fn(); + let prisma: PrismaService; + const upstream = { getOpenAccounts: jest.fn() }; const token = new JwtService().sign( { sub: 'account-owner', type: 'access' }, { secret: process.env.JWT_SECRET }, @@ -24,45 +27,66 @@ describe('All accounts HTTP boundary', () => { beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) - .overrideProvider(TbankAccessService) - .useValue({ withSupportedAccounts }) + .overrideProvider(TbankClient) + .useValue(upstream) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); + await app.listen(0, '127.0.0.1'); + prisma = app.get(PrismaService); + await prisma.user.create({ + data: { + id: 'account-owner', + email: 'all@example.invalid', + passwordHash: 'synthetic', + }, + }); }); - beforeEach(() => { - withSupportedAccounts.mockReset(); - withSupportedAccounts.mockImplementation( - (_user: string, handler: (context: unknown) => unknown) => - handler({ token: 'unused', generation: 'first', accounts: [] }), - ); + beforeEach(async () => { + upstream.getOpenAccounts.mockReset().mockResolvedValue([]); + await prisma.tbankConnection.deleteMany(); + await prisma.tbankConnection.create({ + data: { + userId: 'account-owner', + encryptedToken: app.get(TbankTokenService).encrypt('synthetic'), + connectedAt: new Date(), + membershipAt: new Date(), + membershipRevision: 1, + }, + }); }); afterAll(() => app.close()); it.each(paths)('requires authentication for /all/%s', async (path) => { await request(app.getHttpServer()) - .get(`/api/v1/brokerage-accounts/all/${path}`) + .get( + `/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`, + ) .expect(401); - expect(withSupportedAccounts).not.toHaveBeenCalled(); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); it.each(paths)( 'routes /all/%s to the aggregate with verified identity', async (path) => { const response = await request(app.getHttpServer()) - .get(`/api/v1/brokerage-accounts/all/${path}`) + .get( + `/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`, + ) .set('Authorization', `Bearer ${token}`) .expect(200); - expect(withSupportedAccounts).toHaveBeenCalledWith( - 'account-owner', - expect.any(Function), - ); expect(response.body).not.toHaveProperty('token'); expect(response.body).not.toHaveProperty('generation'); if (path === 'portfolio') - expect(response.body).toMatchObject({ positions: [] }); - else expect(response.body).toMatchObject({ accountId: 'all' }); + expect(response.body).toMatchObject({ + data: { positions: [] }, + meta: { availability: 'complete' }, + }); + else + expect(response.body).toMatchObject({ + meta: { availability: 'complete' }, + }); }, ); @@ -75,7 +99,9 @@ describe('All accounts HTTP boundary', () => { 'income-analytics?from=2026-08-31&to=2026-08-01', ])('validates /all/%s in the configured pipeline', async (path) => { await request(app.getHttpServer()) - .get(`/api/v1/brokerage-accounts/all/${path}`) + .get( + `/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`, + ) .set('Authorization', `Bearer ${token}`) .expect(400); }); @@ -89,9 +115,7 @@ describe('All accounts HTTP boundary', () => { }); it('preserves connection errors without a partial success response', async () => { - withSupportedAccounts.mockRejectedValue( - new ConflictException({ code: 'TBANK_NOT_CONNECTED' }), - ); + await prisma.tbankConnection.deleteMany(); const response = await request(app.getHttpServer()) .get('/api/v1/brokerage-accounts/all/portfolio') .set('Authorization', `Bearer ${token}`) diff --git a/test/bank-cards-durability.cjs b/test/bank-cards-durability.cjs index 8d4c635..23e801a 100644 --- a/test/bank-cards-durability.cjs +++ b/test/bank-cards-durability.cjs @@ -39,6 +39,41 @@ async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode async function http(path,method='GET',body,token,status=200){const r=await fetch(`${origin}/api/v1/${path}`,{method,headers:{...(body?{'content-type':'application/json'}:{}),...(token?{Authorization:`Bearer ${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})});const text=await r.text();assert.equal(r.status,status,`${method} ${path}: ${text}`);return text?JSON.parse(text):null;} function migrate(root){execFileSync(process.execPath,[join(root,'node_modules/prisma/build/index.js'),'migrate','deploy'],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`},stdio:['ignore',openSync(join(dir,`migration-${run}.log`),'w'),'pipe']});} function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeIntegers(true);try{return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();}finally{db.close();}} +// The rollback build must leave every additive T-Bank dataset and work intent intact. +function seedTbank(owner) { + const db = new Database(file); + const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision; + const now = Date.now(); + const scope = {userId:owner,revision,accountId:'durable-account'}; + const exact = {currency:'RUB',value:'-9007199254740993.123456789'}; + const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map(key=>`"${key}"`).join(',')}) VALUES (${Object.keys(row).map(()=>'?').join(',')})`).run(...Object.values(row)); + 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('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}); + insert('TbankInstrument',{provider:'tbank',uid:'durable-instrument',instrumentType:'bond',metadata:JSON.stringify({name:'Synthetic'}),fetchedAt:now}); + insert('TbankCatalogDiscovery',{provider:'tbank',instrumentType:'bond',query:'SYN',instrumentUids:JSON.stringify(['durable-instrument']),fetchedAt:now}); + insert('TbankInstrumentLookup',{provider:'tbank',identifierType:'ticker',identifier:'SYN',matchedUids:JSON.stringify(['durable-instrument']),fetchedAt:now}); + insert('TbankInstrumentSource',{provider:'tbank',instrumentUid:'durable-instrument',source:'tbank',kind:'coupons',rangeKey:'durable',payload:JSON.stringify({events:[]}),fetchedAt:now}); + insert('TbankCandle',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',time:now,close:exact.value,payload:JSON.stringify({close:exact.value}),isComplete:1,fetchedAt:now}); + insert('TbankCandleCoverage',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',from:now-1000,to:now+1000,isComplete:1,fetchedAt:now}); + insert('TbankSyncWork',{id:'durable-work',userId:owner,revision,scopeKey:'durable-intent',intent:JSON.stringify({from:now-1000,to:now+1000}),state:'running',attemptId:'stopped-attempt',processRunId:'stopped-process',priority:0,progress:JSON.stringify({completed:1}),createdAt:now,updatedAt:now}); + insert('TbankSyncStage',{workId:'durable-work',attemptId:'stopped-attempt',sourceKey:'durable',itemKey:'next-row',payload:JSON.stringify({payment:exact})}); + })(); + db.close(); +} +function tbankSnapshot() { + const db = new Database(file,{readonly:true}); + db.defaultSafeIntegers(true); + try { + assert.equal(db.pragma('integrity_check',{simple:true}),'ok'); + assert.deepEqual(db.pragma('foreign_key_check'),[]); + return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE 'Tbank%' OR name='_prisma_migrations') ORDER BY name").all().map(({name})=>[name,db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()])); + } finally {db.close();} +} (async()=>{ try{ migrate(baseline); await start(baseline); @@ -62,6 +97,7 @@ function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeI await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`,'DELETE',null,token,204); card=await http(`bank-cards/${card.id}`,'GET',null,token); card=await http(`bank-cards/${card.id}`,'PATCH',{version:card.version,archived:true},token); + seedTbank(owner); const tbankBefore=tbankSnapshot(); const snapshots={cards:rows('BankCard'),operations:rows('BankCardOperation'),categories:rows('ExpenseCategory')}; await stop(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken; assert.deepEqual(await http('bank-cards','POST',intent,token,201),card); @@ -69,17 +105,18 @@ function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeI await http('bank-cards/operations','POST',deletedIntent,token,410); assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card); await stop(); - assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); + assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close(); await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop(); - assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); + assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop(); const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored; - assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); + assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id)); + assert.deepEqual(tbankSnapshot(),tbankBefore); const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop(); - writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2)); + writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore','foreign key and integrity checks after each boundary','baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2)); console.log(`PASS durability ${dir}`); }finally{await stop();} })().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index a7d3adc..c8b5d0d 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -40,10 +40,14 @@ describe('Bond HTTP contract', () => { app = module.createNestApplication(); configureApp(app); await app.init(); + await app.listen(0, '127.0.0.1'); }); beforeEach(() => { jest.clearAllMocks(); - bonds.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' }); + bonds.get.mockResolvedValue({ + data: { instrumentUid: uid, name: 'Test' }, + meta: { availability: 'complete' }, + }); bonds.search.mockResolvedValue([]); bonds.getByTicker.mockResolvedValue({ instrumentUid: uid, @@ -108,14 +112,14 @@ describe('Bond HTTP contract', () => { .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body).toEqual(metrics); - expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid); + expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid, false); }); it('uses verified identity and supports a direct instrument URL', async () => { await request(app.getHttpServer()) .get(`/api/v1/bonds/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(200); - expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid); + expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid, false); }); it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])( 'resolves ticker %s through the authenticated endpoint', @@ -130,7 +134,11 @@ describe('Bond HTTP contract', () => { instrumentUid: uid, slug: 'SU26238RMFS4', }); - expect(bonds.getByTicker).toHaveBeenCalledWith('bond-reader', ticker); + expect(bonds.getByTicker).toHaveBeenCalledWith( + 'bond-reader', + ticker, + false, + ); }, ); it('returns an explicit conflict for an ambiguous ticker', async () => { @@ -153,13 +161,23 @@ describe('Bond HTTP contract', () => { price: { currency: 'RUB', value: '306.12' }, priceAt: null, }; - bonds.search.mockResolvedValue([item]); + bonds.search.mockResolvedValue({ + data: [item], + meta: { availability: 'complete' }, + }); const response = await request(app.getHttpServer()) .get('/api/v1/bonds?query=SU26238RMFS4') .set('Authorization', `Bearer ${token}`) .expect(200); - expect(bonds.search).toHaveBeenCalledWith('bond-reader', 'SU26238RMFS4'); - expect(response.body).toEqual([item]); + expect(bonds.search).toHaveBeenCalledWith( + 'bond-reader', + 'SU26238RMFS4', + false, + ); + expect(response.body).toEqual({ + data: [item], + meta: { availability: 'complete' }, + }); }); it('preserves safe not-found and upstream errors', async () => { bonds.get.mockRejectedValueOnce(new NotFoundException()); diff --git a/test/fixtures/tbank-read-baseline.json b/test/fixtures/tbank-read-baseline.json new file mode 100644 index 0000000..60b67e9 --- /dev/null +++ b/test/fixtures/tbank-read-baseline.json @@ -0,0 +1,666 @@ +{ + "phase": "baseline", + "runtime": "v24.21.0", + "hardware": { + "cpu": "Apple M1 Pro", + "cpus": 10, + "memoryBytes": 34359738368 + }, + "profile": { + "accounts": 3, + "operationsPerAccount": 100000, + "instruments": 200, + "candlesPerInstrument": 5000, + "upstream": "synthetic immediate responses; transport limiter excluded", + "timing": "HTTP client end-to-end, includes response transfer", + "limitation": "host is not constrained to 4 CPU / 8 GiB; delayed/failing transport and filled persistent datasets remain acceptance checks", + "database": "isolated temporary SQLite with production migrations; synthetic upstream datasets not yet persisted", + "rpcCounting": "TbankClient/MOEX/NCR method invocations, excludes transport retries and limiter; not actual network RPC" + }, + "evidence": [ + { + "route": "/brokerage-accounts", + "cold": { + "ms": 24.586499999999887, + "bytes": 634 + }, + "count": 100, + "p50": 12.34679200000005, + "p95": 24.04108300000007, + "p99": 27.170749999999998, + "max": 27.204958000000033, + "maxBytes": 634, + "rpc": { + "accounts": 100, + "portfolio": 300, + "positions": 300 + } + }, + { + "route": "/brokerage-accounts/baseline-0/portfolio", + "cold": { + "ms": 7.224249999999984, + "bytes": 59510 + }, + "count": 100, + "p50": 7.004541000000245, + "p95": 11.336917000000085, + "p99": 12.390540999999985, + "max": 12.702708000000257, + "maxBytes": 59510, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0 + } + }, + { + "route": "/brokerage-accounts/all/portfolio", + "cold": { + "ms": 6.5479999999997744, + "bytes": 208050 + }, + "count": 100, + "p50": 16.86850000000004, + "p95": 28.5612500000002, + "p99": 31.27654199999961, + "max": 32.9204579999996, + "maxBytes": 208050, + "rpc": { + "accounts": 100, + "portfolio": 0, + "positions": 0, + "metadata": 0 + } + }, + { + "route": "/brokerage-accounts/baseline-0/operations?from=2026-08-01&to=2026-08-31&limit=50", + "cold": { + "ms": 45.70783399999982, + "bytes": 18949 + }, + "count": 100, + "p50": 5.6965000000000146, + "p95": 8.919000000000324, + "p99": 9.326040999999805, + "max": 9.336499999999887, + "maxBytes": 18949, + "rpc": { + "accounts": 100, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0 + } + }, + { + "route": "/brokerage-accounts/all/operations?from=2026-08-01&to=2026-08-31&limit=50", + "cold": { + "ms": 85.20100000000002, + "bytes": 21522 + }, + "count": 100, + "p50": 6.50545800000009, + "p95": 12.267667000000074, + "p99": 16.363749999999982, + "max": 16.446166000000176, + "maxBytes": 21522, + "rpc": { + "accounts": 100, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0 + } + }, + { + "route": "/brokerage-accounts/baseline-0/income-analytics?from=2026-08-01&to=2026-08-31", + "cold": { + "ms": 417.28716699999995, + "bytes": 111178 + }, + "count": 100, + "p50": 1350.6744159999998, + "p95": 2300.347584, + "p99": 2365.9105830000008, + "max": 2645.628458, + "maxBytes": 111178, + "rpc": { + "accounts": 100, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0 + } + }, + { + "route": "/brokerage-accounts/all/income-analytics?from=2026-08-01&to=2026-08-31", + "cold": { + "ms": 873.1202080000003, + "bytes": 318889 + }, + "count": 100, + "p50": 3821.4419160000034, + "p95": 7101.092166000002, + "p99": 7166.68979199999, + "max": 7346.219790999996, + "maxBytes": 318889, + "rpc": { + "accounts": 100, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 21 + } + }, + { + "route": "/brokerage-accounts/baseline-0/events?from=2026-09-01&to=2026-10-31", + "cold": { + "ms": 5.617958000002545, + "bytes": 54910 + }, + "count": 100, + "p50": 13.213250000000698, + "p95": 25.864916999998968, + "p99": 46.8791249999922, + "max": 47.04558299999917, + "maxBytes": 54910, + "rpc": { + "accounts": 100, + "portfolio": 100, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 10000 + } + }, + { + "route": "/brokerage-accounts/all/events?from=2026-09-01&to=2026-10-31", + "cold": { + "ms": 7.403374999994412, + "bytes": 195043 + }, + "count": 100, + "p50": 35.85599999999977, + "p95": 61.01170899999852, + "p99": 72.77300000000105, + "max": 78.4074580000015, + "maxBytes": 195043, + "rpc": { + "accounts": 100, + "portfolio": 300, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 30000 + } + }, + { + "route": "/stocks?query=SYNTH", + "cold": { + "ms": 16.416916000001947, + "bytes": 11471 + }, + "count": 100, + "p50": 4.549584000007599, + "p95": 6.839957999996841, + "p99": 7.346624999991036, + "max": 7.711332999999286, + "maxBytes": 11471, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0 + } + }, + { + "route": "/bonds?query=SYNTH", + "cold": { + "ms": 2.677958000000217, + "bytes": 19906 + }, + "count": 100, + "p50": 4.722166000006837, + "p95": 7.557249999998021, + "p99": 9.096749999996973, + "max": 10.10037500000908, + "maxBytes": 19906, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000", + "cold": { + "ms": 1.367707999990671, + "bytes": 322 + }, + "count": 100, + "p50": 3.424165999997058, + "p95": 5.021249999990687, + "p99": 5.329417000000831, + "max": 5.656291999999667, + "maxBytes": 322, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/quote", + "cold": { + "ms": 1.9067500000091968, + "bytes": 364 + }, + "count": 100, + "p50": 3.7470829999947455, + "p95": 8.519499999994878, + "p99": 10.102916999996523, + "max": 10.448667000004207, + "maxBytes": 364, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/company", + "cold": { + "ms": 1.2771250000077998, + "bytes": 265 + }, + "count": 100, + "p50": 3.6486670000012964, + "p95": 5.218625000008615, + "p99": 5.3912080000009155, + "max": 5.564749999990454, + "maxBytes": 265, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/candles?period=year", + "cold": { + "ms": 2.915374999996857, + "bytes": 26672 + }, + "count": 100, + "p50": 4.780624999999418, + "p95": 8.190875000000233, + "p99": 10.902166999992914, + "max": 11.337415999994846, + "maxBytes": 26672, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/candles?period=all", + "cold": { + "ms": 17.35724999998638, + "bytes": 389549 + }, + "count": 100, + "p50": 18.983874999990803, + "p95": 31.194582999989507, + "p99": 36.280375000002095, + "max": 37.303083000006154, + "maxBytes": 389549, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/dividends", + "cold": { + "ms": 7.10624999999709, + "bytes": 360 + }, + "count": 100, + "p50": 3.821125000002212, + "p95": 5.636291999995592, + "p99": 5.875499999994645, + "max": 6.0989580000023125, + "maxBytes": 360, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001", + "cold": { + "ms": 1.7262079999927664, + "bytes": 594 + }, + "count": 100, + "p50": 4.336542000004556, + "p95": 9.00204200000735, + "p99": 10.431166999987909, + "max": 11.163165999998455, + "maxBytes": 594, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/quote", + "cold": { + "ms": 1.3817500000004657, + "bytes": 394 + }, + "count": 100, + "p50": 4.270417000007001, + "p95": 7.741708000001381, + "p99": 9.260208000006969, + "max": 9.583583999992697, + "maxBytes": 394, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/metrics", + "cold": { + "ms": 1.5154590000020107, + "bytes": 286 + }, + "count": 100, + "p50": 4.241500000003725, + "p95": 7.91508399999293, + "p99": 8.770042000003741, + "max": 9.00604100000055, + "maxBytes": 286, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/candles?period=year", + "cold": { + "ms": 3.042042000000947, + "bytes": 26671 + }, + "count": 100, + "p50": 5.453166000006604, + "p95": 10.431250000008731, + "p99": 13.670707999990555, + "max": 14.3512919999921, + "maxBytes": 26671, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/candles?period=all", + "cold": { + "ms": 16.60329099999217, + "bytes": 389548 + }, + "count": 100, + "p50": 21.239707999993698, + "p95": 52.85929200000828, + "p99": 68.00795900000958, + "max": 74.7853749999922, + "maxBytes": 389548, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/coupons", + "cold": { + "ms": 2.7520840000070166, + "bytes": 394 + }, + "count": 100, + "p50": 3.7073750000126893, + "p95": 6.169125000000349, + "p99": 12.892957999996725, + "max": 13.2072499999922, + "maxBytes": 394, + "rpc": { + "accounts": 0, + "portfolio": 0, + "positions": 0, + "metadata": 0, + "operations": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "share": 0, + "findBonds": 0, + "bond": 0, + "lastPrice": 0, + "tradingStatus": 0, + "candles": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + } + ], + "totalRpc": { + "accounts": 816, + "portfolio": 713, + "positions": 309, + "metadata": 400, + "operations": 33, + "dividends": 101, + "moexEvents": 40400, + "findShares": 31, + "lastPrices": 2, + "share": 35, + "findBonds": 2, + "bond": 30, + "lastPrice": 2, + "tradingStatus": 2, + "candles": 32, + "assetDescription": 1, + "fundamentals": 1, + "coupons": 1, + "moexYield": 1, + "ncrRating": 1 + }, + "baselineCommit": "0862d56c4e4d9dd554556c3992cc073193020ff1" +} diff --git a/test/fixtures/tbank-read-persistent.json b/test/fixtures/tbank-read-persistent.json new file mode 100644 index 0000000..83a9f99 --- /dev/null +++ b/test/fixtures/tbank-read-persistent.json @@ -0,0 +1,831 @@ +{ + "phase": "persistent", + "runtime": "v24.21.0", + "hardware": { + "cpuQuota": "400000 100000", + "memoryLimit": "8589934592", + "cpu": "unknown", + "cpus": 10, + "memoryBytes": 8214851584 + }, + "profile": { + "accounts": 3, + "operationsPerAccount": 100000, + "instruments": 200, + "candlesPerInstrument": 5000, + "upstream": "synthetic failing upstream after population; transport limiter excluded", + "timing": "backend middleware ingress to response finish; client p95 separately", + "limitation": "Synthetic TbankClient boundary counts; no real broker credentials/network. Docker CPU quota 4 and memory limit 8 GiB; VM reports 7.65 GiB available on Apple M1 Pro SSD host. Baseline ran on the host, so compare RPC counts, not cross-host latency.", + "restarts": "Nest application/provider restart against the same SQLite; separate-process crash/restart proof belongs to durability suite" + }, + "evidence": [ + { + "route": "/brokerage-accounts", + "cold": { + "ms": 71.20033399999375, + "bytes": 1847 + }, + "count": 100, + "clientP95": 456.3885420000006, + "p50": 10.983833000005689, + "p95": 11.914583999998285, + "p99": 12.181209000002127, + "max": 17.939458000008017, + "maxBytes": 1847, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/brokerage-accounts/baseline-0/portfolio", + "cold": { + "ms": 57.45741599999019, + "bytes": 124193 + }, + "count": 100, + "clientP95": 512.7660000000033, + "p50": 48.60737499999232, + "p95": 51.47862599999644, + "p99": 60.675208000000566, + "max": 120.15199999998731, + "maxBytes": 124193, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/brokerage-accounts/all/portfolio", + "cold": { + "ms": 54.8744590000133, + "bytes": 273364 + }, + "count": 100, + "clientP95": 559.750875999991, + "p50": 51.72225000000617, + "p95": 53.97833399999945, + "p99": 58.30312500000582, + "max": 72.22691699999268, + "maxBytes": 273364, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/brokerage-accounts/baseline-0/operations?from=2026-08-01&to=2026-08-31&limit=50", + "cold": { + "ms": 12.598417000001064, + "bytes": 35183 + }, + "count": 100, + "clientP95": 76.05224999999336, + "p50": 6.594332999986364, + "p95": 7.303584000008414, + "p99": 7.574791000006371, + "max": 7.955499999996391, + "maxBytes": 35183, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/brokerage-accounts/all/operations?from=2026-08-01&to=2026-08-31&limit=50", + "cold": { + "ms": 14.296457999997074, + "bytes": 28194 + }, + "count": 100, + "clientP95": 111.77812499999709, + "p50": 10.184041000000434, + "p95": 11.09041599999182, + "p99": 11.860207999998238, + "max": 11.948709000003873, + "maxBytes": 28194, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/brokerage-accounts/baseline-0/income-analytics?from=2026-08-01&to=2026-08-31", + "cold": { + "ms": 63.14766599998984, + "bytes": 115083 + }, + "count": 100, + "clientP95": 544.346916999988, + "p50": 52.75404200000048, + "p95": 59.81816599999729, + "p99": 62.35066699999152, + "max": 70.59137499998906, + "maxBytes": 115083, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/brokerage-accounts/all/income-analytics?from=2026-08-01&to=2026-08-31", + "cold": { + "ms": 185.02116700001352, + "bytes": 324998 + }, + "count": 100, + "clientP95": 1801.453708999994, + "p50": 169.87987499999872, + "p95": 189.0762079999986, + "p99": 198.06604099999822, + "max": 243.05916600000637, + "maxBytes": 324998, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/brokerage-accounts/baseline-0/events?from=2026-09-01&to=2026-10-31", + "cold": { + "ms": 235.33958299999358, + "bytes": 267101 + }, + "count": 100, + "clientP95": 1667.5415419999918, + "p50": 160.61579200002598, + "p95": 181.49058300000615, + "p99": 191.35262499999953, + "max": 359.32620899999165, + "maxBytes": 267101, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/brokerage-accounts/all/events?from=2026-09-01&to=2026-10-31", + "cold": { + "ms": 210.63354200002505, + "bytes": 407858 + }, + "count": 100, + "clientP95": 3556.4018769999966, + "p50": 186.65695800000685, + "p95": 382.6084169999813, + "p99": 501.64795900002355, + "max": 561.9104580000276, + "maxBytes": 407858, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/stocks?query=SYNTH", + "cold": { + "ms": 101.64125000001513, + "bytes": 34746 + }, + "count": 100, + "clientP95": 334.57700099999784, + "p50": 24.851041999994777, + "p95": 48.12766699999338, + "p99": 76.38724999999977, + "max": 118.02925000002142, + "maxBytes": 34746, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds?query=SYNTH", + "cold": { + "ms": 30.85858400000143, + "bytes": 42274 + }, + "count": 100, + "clientP95": 260.1413750000065, + "p50": 22.27804199999082, + "p95": 34.133499999996275, + "p99": 38.02966700002435, + "max": 46.303750000020955, + "maxBytes": 42274, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000", + "cold": { + "ms": 17.718416000017896, + "bytes": 1350 + }, + "count": 100, + "clientP95": 107.89837499998976, + "p50": 6.436707999993814, + "p95": 12.759125000011409, + "p99": 14.605541999975685, + "max": 27.046000000002095, + "maxBytes": 1350, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/quote", + "cold": { + "ms": 15.562084000004688, + "bytes": 2018 + }, + "count": 100, + "clientP95": 64.02249999999185, + "p50": 3.446416999999201, + "p95": 7.937167000025511, + "p99": 9.401416000007885, + "max": 15.561542000010377, + "maxBytes": 2018, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/company", + "cold": { + "ms": 26.042874999984633, + "bytes": 1672 + }, + "count": 100, + "clientP95": 68.60291600000346, + "p50": 3.0005000000237487, + "p95": 8.493166000000201, + "p99": 11.896458000002895, + "max": 14.47575000001234, + "maxBytes": 1672, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/candles?period=year", + "cold": { + "ms": 30.428416999988258, + "bytes": 25061 + }, + "count": 100, + "clientP95": 426.01245799998287, + "p50": 9.576167000021087, + "p95": 19.52770900001633, + "p99": 112.54608400000143, + "max": 147.24612500000512, + "maxBytes": 25061, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/candles?period=all", + "cold": { + "ms": 88.14845799998147, + "bytes": 341904 + }, + "count": 100, + "clientP95": 825.1791249999951, + "p50": 59.42475000000559, + "p95": 93.359375, + "p99": 124.13929199997801, + "max": 182.59262500001932, + "maxBytes": 341904, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/stocks/00000000-0000-4000-8000-000000000000/dividends", + "cold": { + "ms": 189.298208000022, + "bytes": 66776 + }, + "count": 100, + "clientP95": 784.9464589999989, + "p50": 60.04541699998663, + "p95": 106.37391699998989, + "p99": 196.45424999998068, + "max": 197.1254590000026, + "maxBytes": 66776, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001", + "cold": { + "ms": 12.731124999991152, + "bytes": 1619 + }, + "count": 100, + "clientP95": 106.46695800000452, + "p50": 3.108875000005355, + "p95": 24.462375000002794, + "p99": 28.717292000015732, + "max": 30.378624999983003, + "maxBytes": 1619, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/quote", + "cold": { + "ms": 87.12529200001154, + "bytes": 2120 + }, + "count": 100, + "clientP95": 117.9898749999993, + "p50": 3.311249999998836, + "p95": 20.9320000000007, + "p99": 26.06058399999165, + "max": 37.378333000000566, + "maxBytes": 2120, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/metrics", + "cold": { + "ms": 46.56666700000642, + "bytes": 3104 + }, + "count": 100, + "clientP95": 53.023125000006985, + "p50": 2.992292000009911, + "p95": 5.0856670000066515, + "p99": 5.71316700000898, + "max": 9.648791999992682, + "maxBytes": 3104, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/candles?period=year", + "cold": { + "ms": 24.397041999996873, + "bytes": 25059 + }, + "count": 100, + "clientP95": 162.17016700000386, + "p50": 8.241167000000132, + "p95": 25.821332999999868, + "p99": 31.409500000008848, + "max": 43.631125000014435, + "maxBytes": 25059, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/candles?period=all", + "cold": { + "ms": 81.4910830000008, + "bytes": 341902 + }, + "count": 100, + "clientP95": 962.9355839999916, + "p50": 56.31050000002142, + "p95": 126.877416000003, + "p99": 147.04279199999291, + "max": 187.77900000000955, + "maxBytes": 341902, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + }, + { + "route": "/bonds/00000000-0000-4000-8000-000000000001/coupons", + "cold": { + "ms": 94.6564579999831, + "bytes": 66804 + }, + "count": 100, + "clientP95": 1000.0864169999841, + "p50": 68.95912499999395, + "p95": 139.95691700000316, + "p99": 235.2280000000028, + "max": 284.38629199998104, + "maxBytes": 66804, + "rpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + } + } + ], + "totalRpc": { + "portfolio": 0, + "positions": 0, + "metadata": 0, + "share": 0, + "candles": 0, + "bond": 0, + "dividends": 0, + "moexEvents": 0, + "findShares": 0, + "lastPrices": 0, + "findBonds": 0, + "tradingStatus": 0, + "assetDescription": 0, + "fundamentals": 0, + "coupons": 0, + "moexYield": 0, + "ncrRating": 0 + }, + "warmupRpc": { + "portfolio": 3, + "positions": 3, + "metadata": 200, + "share": 100, + "candles": 200, + "bond": 101, + "dividends": 101, + "moexEvents": 100, + "findShares": 2, + "lastPrices": 2, + "findBonds": 2, + "tradingStatus": 2, + "assetDescription": 1, + "fundamentals": 1, + "coupons": 2, + "moexYield": 1, + "ncrRating": 1 + }, + "databaseBytes": 619343872, + "oldRpc": 42913, + "newRpc": 0, + "reduction": 1, + "staleResponsesMs": 1716.9662929999758, + "staleLogicalPass": { + "portfolio": 1, + "positions": 1, + "retries": 0 + }, + "coldImportMs": 18.279999999998836 +} diff --git a/test/fixtures/tbank-sync-crash.cjs b/test/fixtures/tbank-sync-crash.cjs new file mode 100644 index 0000000..3f1d6a4 --- /dev/null +++ b/test/fixtures/tbank-sync-crash.cjs @@ -0,0 +1,85 @@ +// This process is deliberately killed by the SQLite suite at durable boundaries. +require('../e2e-env.cjs'); +const { ConfigService } = require('@nestjs/config'); +const { PrismaService } = require('../../dist/src/prisma/prisma.service'); +const { + TbankTokenService, +} = require('../../dist/src/integrations/tbank/tbank-token.service'); +const { + TbankAccessService, +} = require('../../dist/src/integrations/tbank/tbank-access.service'); +const { + BrokerageOperationsRepository, +} = require('../../dist/src/modules/investments/operations/infrastructure/brokerage-operations.repository'); +const { + BrokerageOperationsSyncService, +} = require('../../dist/src/modules/investments/operations/application/brokerage-operations-sync.service'); +const mode = process.argv[2]; +const prisma = new PrismaService(new ConfigService(process.env)); +const repository = new BrokerageOperationsRepository(prisma); +const tokens = new TbankTokenService(new ConfigService(process.env)); +const stopped = new Promise(() => {}); +let handle; +let pages = 0; +const checkpoint = () => process.send({ checkpoint: mode, handle }); +const upstream = { + async getOperationsByCursor() { + pages += 1; + if (mode === 'between-pages' && pages === 2) { + checkpoint(); + return stopped; + } + return { + hasNext: mode === 'between-pages', + nextCursor: mode === 'between-pages' ? 'discard-after-restart' : '', + items: [ + { + id: 'crash-row', + date: { seconds: '1768000000' }, + payment: { units: '10', nano: 1, currency: 'rub' }, + }, + ], + }; + }, +}; +const publish = repository.publish.bind(repository); +repository.publish = async (...args) => { + if (mode === 'before-publish') { + checkpoint(); + return stopped; + } + const result = await publish(...args); + if (mode === 'after-publish') { + checkpoint(); + return stopped; + } + return result; +}; +const service = new BrokerageOperationsSyncService( + repository, + new TbankAccessService(prisma, tokens, upstream), + upstream, + { + event() { + process.send({ error: 'Unexpected worker persistence failure' }); + }, + }, +); +process.on('message', () => {}); +(async () => { + await prisma.$connect(); + handle = await service.request( + 'owner', + [ + { + accountId: 'account', + from: new Date('2026-01-01Z'), + to: new Date('2026-02-01Z'), + }, + ], + true, + ); +})().catch((error) => { + process.send({ error: error.message }); + process.exitCode = 1; +}); diff --git a/test/portfolio-structure.e2e-spec.ts b/test/portfolio-structure.e2e-spec.ts index 49b22fb..c95bd8f 100644 --- a/test/portfolio-structure.e2e-spec.ts +++ b/test/portfolio-structure.e2e-spec.ts @@ -4,40 +4,23 @@ import { Test } from '@nestjs/testing'; import type { Server } from 'node:http'; import request from 'supertest'; import { AppModule } from '../src/app.module'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { setTimeout as delay } from 'node:timers/promises'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; -import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service'; describe('Portfolio structure HTTP contract', () => { let app: INestApplication; - const context = { - token: 'fixture', - generation: 'fixture', - accounts: [ - { id: 'a', name: 'A' }, - { id: 'b', name: 'B' }, - ], - }; const tbank = { + validateToken: jest.fn().mockResolvedValue([ + { id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' }, + { id: 'b', name: 'B', type: 'ACCOUNT_TYPE_TINKOFF' }, + ]), getPortfolio: jest.fn(), getPositions: jest.fn(), getInstrumentByFigi: jest.fn().mockResolvedValue(null), getInstrumentByUid: jest.fn().mockResolvedValue(null), }; - const access = { - withConnection: (_user: string, action: (value: unknown) => unknown) => - action(context), - withSupportedAccounts: ( - _user: string, - action: (value: unknown) => unknown, - ) => action(context), - withAccountForConnection: ( - _context: unknown, - _id: string, - action: () => unknown, - ) => action(), - }; const token = new JwtService().sign( { sub: 'structure-user', type: 'access' }, { secret: process.env.JWT_SECRET }, @@ -45,17 +28,26 @@ describe('Portfolio structure HTTP contract', () => { beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) - .overrideProvider(TbankAccessService) - .useValue(access) .overrideProvider(TbankClient) .useValue(tbank) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); + await app.get(PrismaService).user.create({ + data: { + id: 'structure-user', + email: 'structure@example.invalid', + passwordHash: 'fixture', + }, + }); }); - beforeEach(() => { - app.get(PortfolioService).invalidate('structure-user'); + beforeEach(async () => { + await request(app.getHttpServer()) + .put('/api/v1/tbank-connection') + .set('Authorization', `Bearer ${token}`) + .send({ token: 'fixture' }) + .expect(200); tbank.getPortfolio.mockResolvedValue({ positions: [ { @@ -70,15 +62,32 @@ describe('Portfolio structure HTTP contract', () => { }); }); afterAll(() => app.close()); + async function load(id: string) { + await request(app.getHttpServer()) + .get(`/api/v1/brokerage-accounts/${id}/portfolio`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + for (let index = 0; index < 200; index += 1) { + if ( + !(await app + .get(PrismaService) + .tbankSyncWork.count({ where: { state: 'running' } })) + ) + return; + await delay(5); + } + throw new Error('Portfolio did not settle'); + } it.each(['a', 'all'])( 'serializes exact structure including cash for %s', async (id) => { + await load(id); const response = await request(app.getHttpServer()) .get(`/api/v1/brokerage-accounts/${id}/portfolio`) .set('Authorization', `Bearer ${token}`) .expect(200); - expect(response.body).toMatchObject({ + expect((response.body as { data: unknown }).data).toMatchObject({ structure: { unvaluedCategories: [], currencies: [ @@ -111,11 +120,12 @@ describe('Portfolio structure HTTP contract', () => { tbank.getPortfolio.mockResolvedValue({ positions: [{ instrumentType: 'bond', quantity: { units: 1 } }], }); + await load('a'); const response = await request(app.getHttpServer()) .get('/api/v1/brokerage-accounts/a/portfolio') .set('Authorization', `Bearer ${token}`) .expect(200); - expect(response.body).toMatchObject({ + expect((response.body as { data: unknown }).data).toMatchObject({ structure: { unvaluedCategories: [{ type: 'BOND', count: 1 }], currencies: [ @@ -136,11 +146,12 @@ describe('Portfolio structure HTTP contract', () => { tbank.getPositions.mockImplementation((_token: string, id: string) => ({ money: [{ currency: 'RUB', units: id === 'a' ? -10 : 40 }], })); + await load('all'); const response = await request(app.getHttpServer()) .get('/api/v1/brokerage-accounts/all/portfolio') .set('Authorization', `Bearer ${token}`) .expect(200); - expect(response.body).toMatchObject({ + expect((response.body as { data: unknown }).data).toMatchObject({ structure: { currencies: [ { diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index 5b270b8..6e04520 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -39,10 +39,14 @@ describe('Stock HTTP contract', () => { app = module.createNestApplication(); configureApp(app); await app.init(); + await app.listen(0, '127.0.0.1'); }); beforeEach(() => { jest.clearAllMocks(); - stocks.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' }); + stocks.get.mockResolvedValue({ + data: { instrumentUid: uid, name: 'Test' }, + meta: { availability: 'complete' }, + }); stocks.search.mockResolvedValue([]); stocks.getByTicker.mockResolvedValue({ instrumentUid: uid, slug: 'SBERP' }); }); @@ -72,7 +76,7 @@ describe('Stock HTTP contract', () => { .get(`/api/v1/stocks/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(200); - expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid); + expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid, false); }); it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])( 'resolves ticker %s through the authenticated endpoint', @@ -87,7 +91,11 @@ describe('Stock HTTP contract', () => { instrumentUid: uid, slug: 'SBERP', }); - expect(stocks.getByTicker).toHaveBeenCalledWith('stock-reader', ticker); + expect(stocks.getByTicker).toHaveBeenCalledWith( + 'stock-reader', + ticker, + false, + ); }, ); it('returns an explicit conflict for an ambiguous ticker', async () => { @@ -110,13 +118,19 @@ describe('Stock HTTP contract', () => { price: { currency: 'RUB', value: '306.12' }, priceAt: null, }; - stocks.search.mockResolvedValue([item]); + stocks.search.mockResolvedValue({ + data: [item], + meta: { availability: 'complete' }, + }); const response = await request(app.getHttpServer()) .get('/api/v1/stocks?query=SBER') .set('Authorization', `Bearer ${token}`) .expect(200); - expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER'); - expect(response.body).toEqual([item]); + expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER', false); + expect(response.body).toEqual({ + data: [item], + meta: { availability: 'complete' }, + }); }); it('preserves safe not-found and upstream errors', async () => { stocks.get.mockRejectedValueOnce(new NotFoundException()); diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts new file mode 100644 index 0000000..73e4b0d --- /dev/null +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -0,0 +1,379 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { CandleService } from '../src/modules/investments/instruments/application/candle.service'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository'; +import { + candleGaps, + type CandleRange, +} from '../src/modules/investments/instruments/domain/candle-coverage'; + +describe('atomic shared candle reconciliation (SQLite)', () => { + const prisma = new PrismaService( + new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), + ); + const repository = new CandleRepository(prisma); + const owner = { userId: 'owner', revision: 'revision' }; + const tokens = new TbankTokenService( + new ConfigService({ + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + }) as never, + ); + const upstream = { getCandles: jest.fn() }; + const access = new TbankAccessService(prisma, tokens, upstream as never); + const instrument = { + uid: 'uid', + currency: 'rub', + first_1dayCandleDate: { seconds: String(Date.parse('2020-01-01Z') / 1000) }, + }; + const sources = { + ensureDetail: jest.fn().mockResolvedValue(instrument), + readMany: jest.fn().mockResolvedValue([ + { + input: { uid: 'uid', instrumentType: 'share', kind: 'detail' }, + instrument, + meta: { + availability: 'complete', + freshness: 'fresh', + lastSuccessfulAt: null, + resourceKey: 'detail/uid', + dataRevision: '1', + stateVersion: 'detail-v1', + sync: { id: null, state: 'idle', errorCode: null }, + }, + }, + ]), + }; + const createService = () => + new CandleService( + repository, + sources as never, + access, + upstream as never, + { event: jest.fn() } as never, + ); + async function settled() { + for (let i = 0; i < 500; i++) { + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + return; + await delay(5); + } + throw new Error('Candle work did not settle'); + } + + const range = { from: new Date('2026-01-01Z'), to: new Date('2026-01-04Z') }; + const candle = { + time: '2026-01-02T00:00:00.000Z', + close: '100.000000001', + isComplete: true, + }; + async function reserve(ranges: CandleRange[] = [range]) { + const result = await repository.reserve( + owner, + 'candles:uid', + { uid: 'uid', ranges }, + 'run', + new Set(), + ); + if (!result) throw new Error('Missing attempt'); + return { ...owner, id: result.work.id, attemptId: result.work.attemptId }; + } + beforeAll(async () => { + await prisma.user.create({ + data: { + id: 'owner', + email: 'owner@example.invalid', + passwordHash: 'synthetic', + }, + }); + }); + beforeEach(async () => { + jest.clearAllMocks(); + upstream.getCandles.mockReset().mockResolvedValue([]); + await prisma.tbankConnection.deleteMany(); + await prisma.tbankInstrument.deleteMany(); + await prisma.tbankConnection.create({ + data: { + ...owner, + encryptedToken: tokens.encrypt('synthetic'), + connectedAt: new Date(), + }, + }); + await prisma.tbankInstrument.create({ + data: { + provider: 'tbank', + uid: 'uid', + instrumentType: 'share', + metadata: {}, + fetchedAt: new Date(), + }, + }); + }); + afterAll(async () => prisma.$disconnect()); + it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => { + const attempt = await reserve(); + await repository.stage( + attempt, + range, + [candle, candle, { ...candle, time: range.to.toISOString() }], + new Date('2026-01-05Z'), + ); + expect((await repository.read('uid', range)).data).toEqual([]); + await repository.publish(attempt, { uid: 'uid', ranges: [range] }); + await prisma.$disconnect(); + const read = await new CandleRepository(prisma).read('uid', range); + expect(read.data).toEqual([candle]); + expect(candleGaps(range, read.coverage, new Date('2030-01-01Z'))).toEqual( + [], + ); + const emptyRange = { from: range.to, to: new Date('2026-01-06Z') }; + const empty = await reserve([emptyRange]); + await repository.stage(empty, emptyRange, [], new Date('2026-01-07Z')); + await repository.publish(empty, { uid: 'uid', ranges: [emptyRange] }); + const readEmpty = await repository.read('uid', emptyRange); + expect(readEmpty.data).toEqual([]); + expect( + candleGaps(emptyRange, readEmpty.coverage, new Date('2030-01-01Z')), + ).toEqual([]); + }); + it('keeps the entire previous snapshot and timestamps when a later refresh window fails', async () => { + const initial = await reserve(); + await repository.stage(initial, range, [candle], new Date('2026-01-05Z')); + await repository.publish(initial, { uid: 'uid', ranges: [range] }); + const before = await repository.read('uid', range); + const windows = [ + { from: range.from, to: new Date('2026-01-03Z') }, + { from: new Date('2026-01-03Z'), to: range.to }, + ]; + const retry = await reserve(windows); + await repository.stage( + retry, + windows[0], + [{ ...candle, close: '2' }], + new Date('2026-01-06Z'), + ); + await expect( + repository.publish(retry, { uid: 'uid', ranges: windows }), + ).rejects.toThrow('Incomplete candle reconciliation'); + expect(await repository.read('uid', range)).toEqual(before); + await repository.fail(retry, 'TBANK_UNAVAILABLE'); + expect(await repository.read('uid', range)).toEqual(before); + expect(await prisma.tbankSyncStage.count()).toBe(0); + }); + it('reconciles deleted rows atomically and preserves coverage outside the requested period', async () => { + const initial = await reserve(); + await repository.stage(initial, range, [candle], new Date('2026-01-05Z')); + await repository.publish(initial, { uid: 'uid', ranges: [range] }); + const middle = { + from: new Date('2026-01-02Z'), + to: new Date('2026-01-03Z'), + }; + const retry = await reserve([middle]); + await repository.stage(retry, middle, [], new Date('2026-01-06Z')); + expect((await repository.read('uid', range)).data).toEqual([candle]); + await repository.publish(retry, { uid: 'uid', ranges: [middle] }); + const after = await repository.read('uid', range); + expect(after.data).toEqual([]); + expect(candleGaps(range, after.coverage, new Date('2030-01-01Z'))).toEqual( + [], + ); + }); + it('fences superseded and deleted attempts including shared coverage timestamps', async () => { + const old = await reserve(); + await repository.stage(old, range, [candle], new Date('2026-01-05Z')); + const current = await reserve(); + expect(await repository.publish(old, { uid: 'uid', ranges: [range] })).toBe( + false, + ); + expect( + await repository.stage(old, range, [candle], new Date('2026-01-06Z')), + ).toBe(false); + await repository.stage(current, range, [candle], new Date('2026-01-06Z')); + await prisma.tbankConnection.deleteMany(); + expect( + await repository.publish(current, { uid: 'uid', ranges: [range] }), + ).toBe(false); + expect((await repository.read('uid', range)).coverage).toEqual([]); + }); + it('coalesces twenty reads, makes observe passive and reuses closed windows after reopen', async () => { + const service = createService(); + const input = { + uid: 'uid', + instrumentType: 'share' as const, + period: 'all' as const, + }; + let release: (value: unknown[]) => void = () => {}; + const blocked = new Promise((resolve) => { + release = resolve; + }); + upstream.getCandles.mockReturnValueOnce(blocked); + const cold = await Promise.all( + Array.from({ length: 20 }, () => service.read('owner', input)), + ); + expect(cold.every((row) => row.data === null)).toBe(true); + expect(await prisma.tbankSyncWork.count()).toBe(1); + await service.read('owner', input, true); + release([]); + await settled(); + const count = upstream.getCandles.mock.calls.length; + expect(count).toBeGreaterThan(1); + for (const call of upstream.getCandles.mock.calls as [ + string, + string, + Date, + Date, + ][]) + expect(call[3].valueOf() - call[2].valueOf()).toBeLessThanOrEqual( + 365 * 86400000, + ); + await prisma.$disconnect(); + const reopened = createService(); + expect((await reopened.read('owner', input)).data?.candles).toEqual([]); + expect(upstream.getCandles).toHaveBeenCalledTimes(count); + await prisma.tbankCandleCoverage.updateMany({ + where: { isComplete: false }, + data: { fetchedAt: new Date(0) }, + }); + const stale = await reopened.read('owner', input, true); + expect(stale.meta.availability).toBe('complete'); + expect(stale.meta.freshness).toBe('stale'); + expect(stale.meta.coverage?.missingRanges).toEqual([]); + expect(stale.meta.coverage?.completeRanges.length).toBeGreaterThan(0); + expect(stale.meta.coverage?.historyState).toBe('complete'); + expect(upstream.getCandles).toHaveBeenCalledTimes(count); + await reopened.read('owner', input); + await settled(); + expect(upstream.getCandles).toHaveBeenCalledTimes(count + 1); + }); + it('recovers abandoned work only on a new ordinary request', async () => { + const input = { + uid: 'uid', + instrumentType: 'share' as const, + period: 'month' as const, + }; + const abandoned = await repository.reserve( + owner, + 'candles:uid:month', + { uid: 'uid', ranges: [] }, + 'old-process', + new Set(), + ); + const reopened = createService(); + await reopened.read('owner', input, true); + expect(upstream.getCandles).not.toHaveBeenCalled(); + expect( + (await repository.work('owner', 'revision', 'candles:uid:month')) + ?.attemptId, + ).toBe(abandoned?.work.attemptId); + await reopened.read('owner', input); + await settled(); + expect(upstream.getCandles.mock.calls.length).toBeGreaterThan(1); + expect( + (await repository.work('owner', 'revision', 'candles:uid:month')) + ?.attemptId, + ).not.toBe(abandoned?.work.attemptId); + }); + it('retains full shown history when a forced reread fails in its second window', async () => { + const input = { + uid: 'uid', + instrumentType: 'share' as const, + period: 'all' as const, + }; + const service = createService(); + await service.read('owner', input); + await settled(); + const before = await prisma.tbankCandleCoverage.findMany(); + upstream.getCandles + .mockResolvedValueOnce([ + { + time: { seconds: '1577923200' }, + close: { units: '123', nano: 1 }, + isComplete: true, + }, + ]) + .mockRejectedValueOnce(new Error('second window unavailable')); + await service.request('owner', 'revision', input, new Date(), true); + await settled(); + expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before); + expect(await prisma.tbankCandle.count()).toBe(0); + const read = await service.read('owner', input, true); + expect(read.meta.sync.state).toBe('failed'); + expect(read.data?.candles).toEqual([]); + }); + it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => { + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + upstream.getCandles.mockResolvedValue([ + { + time: { seconds: String(now.valueOf() / 1000) }, + close: { units: '99', nano: 123456789 }, + isComplete: false, + }, + ]); + const service = createService(); + const input = { + uid: 'uid', + instrumentType: 'bond' as const, + period: 'month' as const, + }; + await service.read('owner', input); + await settled(); + const result = await service.read('owner', input, true); + expect(result.data?.currency).toBeNull(); + expect(result.data?.candles).toEqual([ + { time: now.toISOString(), close: '99.123456789', isComplete: false }, + ]); + expect( + await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }), + ).toBe(1); + }); + it('prefetches only current supported portfolio instruments and lets screen work run between history windows', async () => { + await prisma.tbankInstrument.create({ + data: { + provider: 'tbank', + uid: 'opened', + instrumentType: 'share', + metadata: {}, + fetchedAt: new Date(), + }, + }); + const service = createService(); + let release: (value: unknown[]) => void = () => {}; + const blocked = new Promise((resolve) => { + release = resolve; + }); + upstream.getCandles.mockReturnValueOnce(blocked); + await service.prefetch('owner', 'revision', [ + { instrumentUid: 'uid', type: 'STOCK' }, + { instrumentUid: 'uid', type: 'STOCK' }, + { instrumentUid: 'not-supported', type: 'FUND' }, + { instrumentUid: null, type: 'BOND' }, + ]); + for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) + await delay(5); + expect((upstream.getCandles.mock.calls as unknown[][])[0]?.[1]).toBe('uid'); + await service.read('owner', { + uid: 'opened', + instrumentType: 'share', + period: 'month', + }); + release([]); + await settled(); + expect((upstream.getCandles.mock.calls as unknown[][])[1]?.[1]).toBe( + 'opened', + ); + expect( + new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])), + ).toEqual(new Set(['uid', 'opened'])); + const count = upstream.getCandles.mock.calls.length; + await service.prefetch('owner', 'revision', [ + { instrumentUid: 'uid', type: 'STOCK' }, + ]); + expect(upstream.getCandles).toHaveBeenCalledTimes(count); + expect( + (await repository.work('owner', 'revision', 'candles:opened:all'))?.state, + ).toBe('complete'); + }); +}); diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts new file mode 100644 index 0000000..23f66ff --- /dev/null +++ b/test/tbank-catalog-discovery.e2e-spec.ts @@ -0,0 +1,334 @@ +import assert from 'node:assert/strict'; +import { ConfigService } from '@nestjs/config'; +import { setTimeout as delay } from 'node:timers/promises'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { CatalogDiscoveryService } from '../src/modules/investments/instruments/application/catalog-discovery.service'; +import { + CatalogDiscoveryRepository, + type CatalogInstrument, + type CatalogQuery, +} from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; + +// Real transactions prove query coverage, private work ownership and late-publication fences. +describe('shared catalog discovery (SQLite)', () => { + const prisma = new PrismaService( + new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), + ); + const tokens = new TbankTokenService( + new ConfigService({ + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + }) as never, + ); + const upstream = { findShares: jest.fn(), findBonds: jest.fn() }; + const access = new TbankAccessService(prisma, tokens, upstream as never); + const repository = new CatalogDiscoveryRepository(prisma); + const createService = () => + new CatalogDiscoveryService( + repository, + access, + upstream as never, + { event: jest.fn() } as never, + { requestMany: jest.fn().mockResolvedValue([]) } as never, + ); + let service: CatalogDiscoveryService; + const query: CatalogQuery = { instrumentType: 'share', query: 'SBER' }; + const instrument = { + uid: 'uid', + name: 'Synthetic share', + ticker: 'SBER', + isin: 'TEST', + instrumentKind: 'INSTRUMENT_TYPE_SHARE', + }; + const seed = (userId: string) => + prisma.tbankConnection.create({ + data: { + userId, + revision: `${userId}-revision`, + encryptedToken: tokens.encrypt(`synthetic-${userId}`), + connectedAt: new Date(), + }, + }); + async function settled() { + for (let i = 0; i < 300; i++) { + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + return; + await delay(5); + } + throw new Error('Catalog work did not settle'); + } + beforeAll(async () => { + await prisma.$connect(); + for (const id of ['owner', 'other']) + await prisma.user.create({ + data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, + }); + }); + afterAll(async () => prisma.$disconnect()); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + await prisma.tbankCatalogDiscovery.deleteMany(); + await prisma.tbankInstrument.deleteMany(); + await seed('owner'); + await seed('other'); + jest.resetAllMocks(); + upstream.findShares.mockResolvedValue([instrument]); + upstream.findBonds.mockResolvedValue([ + { ...instrument, uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' }, + ]); + service = createService(); + }); + it('persists normalized query identity and confirmed empty separately from unknown, across users and reopen', async () => { + const cold = await service.read('owner', { ...query, query: ' sber ' }); + expect(cold.data).toBeNull(); + expect(cold.meta.availability).toBe('missing'); + await settled(); + await prisma.$disconnect(); + service = createService(); + const decrypt = jest.spyOn(tokens, 'decrypt'); + const warm = await service.read('other', query); + expect(warm.data).toEqual([instrument]); + expect(warm.meta.availability).toBe('complete'); + expect(warm.meta.sync.id).toBeNull(); + expect(decrypt).not.toHaveBeenCalled(); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + expect( + (await service.read('other', { ...query, query: 'GAZP' }, true)).data, + ).toBeNull(); + upstream.findShares.mockResolvedValue([]); + await service.read('other', { ...query, query: 'GAZP' }); + await settled(); + expect( + (await service.read('owner', { ...query, query: 'GAZP' })).data, + ).toEqual([]); + expect((await service.read('owner', query)).data).toEqual([instrument]); + expect(upstream.findShares).toHaveBeenCalledTimes(2); + decrypt.mockRestore(); + await service.read('owner', { ...query, instrumentType: 'bond' }); + await settled(); + expect( + (await service.read('other', { ...query, instrumentType: 'bond' })) + .data?.[0].uid, + ).toBe('bond'); + }); + it('keeps selection ordering, deduplicates before limiting, and retains identity without enrichment or private fields', async () => { + upstream.findShares.mockResolvedValue([ + { ...instrument, uid: 'unavailable', apiTradeAvailableFlag: false }, + { + ...instrument, + uid: 'preferred', + apiTradeAvailableFlag: true, + classCode: 'TQBR', + quantity: '999', + estimatedAmount: 'secret', + }, + ...Array.from({ length: 40 }, (_, i) => ({ + ...instrument, + uid: `other-${String(i).padStart(2, '0')}`, + isin: undefined, + ticker: 'OTHER', + })), + { + ...instrument, + uid: 'wrong-kind', + instrumentKind: 'INSTRUMENT_TYPE_BOND', + }, + ]); + await service.read('owner', query); + await settled(); + const saved = await service.read('other', query); + expect(saved.data).toHaveLength(30); + expect(saved.data?.[0].uid).toBe('preferred'); + expect( + saved.data?.some((row) => + ['unavailable', 'wrong-kind'].includes(row.uid), + ), + ).toBe(false); + expect( + JSON.stringify(await prisma.tbankInstrumentSource.findMany()), + ).not.toMatch( + /quantity|estimatedAmount|secret|owner-revision|synthetic-owner/, + ); + expect(await prisma.tbankInstrumentLookup.count()).toBe(0); + }); + it('does not overwrite detailed metadata or its timestamp while refreshing discovery', async () => { + const detailed = { ...instrument, sector: 'financial', lot: 10 }; + const fetchedAt = new Date('2026-01-01'); + await prisma.tbankInstrument.create({ + data: { + provider: 'tbank', + uid: 'uid', + instrumentType: 'share', + metadata: detailed, + fetchedAt, + }, + }); + await service.read('owner', query); + await settled(); + const row = await prisma.tbankInstrument.findUniqueOrThrow({ + where: { provider_uid: { provider: 'tbank', uid: 'uid' } }, + }); + expect(row.metadata).toEqual(detailed); + expect(row.fetchedAt).toEqual(fetchedAt); + }); + it('coalesces twenty concurrent requests and forces with private handles and one upstream call', async () => { + const blocked = deferred(); + upstream.findShares.mockReturnValue(blocked.promise); + const reads = await Promise.all( + Array.from({ length: 20 }, (_, i) => + service.read(i % 2 ? 'owner' : 'other', query), + ), + ); + await service.request('owner', 'owner-revision', query, true); + await delay(10); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + expect(new Set(reads.map((read) => read.meta.sync.id)).size).toBe(2); + blocked.resolve([instrument]); + await settled(); + expect( + await prisma.tbankSyncWork.count({ where: { state: 'complete' } }), + ).toBe(2); + }); + it.each(['delete', 'replace'])( + 'discards late %s publication and does not borrow observer credentials', + async (action) => { + const blocked = deferred(); + upstream.findShares.mockReturnValueOnce(blocked.promise); + await service.read('owner', query); + await service.read('other', query); + await delay(10); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + if (action === 'replace') + await prisma.tbankConnection.create({ + data: { + userId: 'owner', + revision: 'replacement', + encryptedToken: tokens.encrypt('replacement'), + connectedAt: new Date(), + }, + }); + blocked.resolve([instrument]); + await settled(); + expect(await prisma.tbankCatalogDiscovery.count()).toBe(0); + expect(await prisma.tbankInstrument.count()).toBe(0); + expect( + (await service.read('other', query, true)).meta.sync.errorCode, + ).toBe('TBANK_UNAVAILABLE'); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + await service.read('other', query); + await settled(); + expect((await service.read('other', query, true)).data).toEqual([ + instrument, + ]); + }, + ); + it('preserves a successful result and its timestamp after a failed force without observe retry', async () => { + await service.read('owner', query); + await settled(); + const before = await repository.read(query); + upstream.findShares.mockRejectedValue(new Error('outage')); + await service.request('owner', 'owner-revision', query, true); + await settled(); + for (let i = 0; i < 5; i++) await service.read('owner', query, true); + expect(await repository.read(query)).toEqual(before); + expect((await service.read('owner', query, true)).meta.sync.state).toBe( + 'failed', + ); + expect(upstream.findShares).toHaveBeenCalledTimes(2); + expect((await service.read('other', query)).meta.sync.errorCode).toBeNull(); + }); + it('isolates invalid credentials from shared empty lookup and another user', async () => { + const blocked = deferred(); + upstream.findShares.mockReturnValueOnce(blocked.promise); + await service.read('owner', query); + await service.read('other', query); + blocked.reject(new Error('INVALID_TBANK_TOKEN')); + await settled(); + expect(await prisma.tbankCatalogDiscovery.count()).toBe(0); + expect( + ( + await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }) + ).invalidToken, + ).toBe(true); + expect((await service.read('other', query, true)).meta.sync.errorCode).toBe( + 'TBANK_UNAVAILABLE', + ); + await service.read('other', query); + await settled(); + expect((await service.read('other', query, true)).data).toEqual([ + instrument, + ]); + }); + it('recovers abandoned work only on the next ordinary request and rejects superseded attempts', async () => { + const abandoned = await repository.reserve( + { userId: 'owner', revision: 'owner-revision' }, + query, + 'old-process', + new Set(), + ); + assert(abandoned); + service = createService(); + await service.read('owner', query, true); + await delay(10); + expect(upstream.findShares).not.toHaveBeenCalled(); + await service.read('owner', query); + await settled(); + expect( + await repository.publish( + { + userId: 'owner', + revision: 'owner-revision', + id: abandoned.work.id, + attemptId: abandoned.work.attemptId, + }, + query, + [], + new Date(), + ), + ).toBe(false); + expect((await service.read('owner', query, true)).data).toEqual([ + instrument, + ]); + }); + it('refreshes at the daily boundary while stale observe and fresh reads perform zero RPC', async () => { + await service.read('owner', query); + await settled(); + const now = Date.now(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(now); + try { + await prisma.tbankCatalogDiscovery.updateMany({ + data: { fetchedAt: new Date(now - 86_399_000) }, + }); + await service.read('other', query); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + await prisma.tbankCatalogDiscovery.updateMany({ + data: { fetchedAt: new Date(now - 86_400_000) }, + }); + expect((await service.read('other', query, true)).meta.freshness).toBe( + 'stale', + ); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + await service.read('other', query); + await settled(); + expect(upstream.findShares).toHaveBeenCalledTimes(2); + } finally { + clock.mockRestore(); + } + }); +}); +function deferred() { + let resolve: (value: T) => void = () => { + throw new Error('Uninitialized deferred'); + }; + let reject: (error: Error) => void = () => { + throw new Error('Uninitialized deferred'); + }; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} diff --git a/test/tbank-catalog-local.e2e-spec.ts b/test/tbank-catalog-local.e2e-spec.ts new file mode 100644 index 0000000..551ab71 --- /dev/null +++ b/test/tbank-catalog-local.e2e-spec.ts @@ -0,0 +1,903 @@ +import { MoexClient } from '../src/integrations/moex/moex.api'; +import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; +import type { + StockCandlesResourceDto, + StockCompanyResourceDto, + StockQuoteResourceDto, +} from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; +import type { + BondMetricsResourceDto, + BondQuoteResourceDto, +} from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; +import assert from 'node:assert/strict'; +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; +import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; +import type { + StockResourceDto, + StockSearchResourceDto, +} from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; +import type { BondSearchResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; + +// HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races. +describe('local stock/bond search (SQLite HTTP)', () => { + let app: INestApplication; + let prisma: PrismaService; + const upstream = { + findShares: jest.fn(), + findBonds: jest.fn(), + getShare: jest.fn(), + getBond: jest.fn(), + getLastPrices: jest.fn(), + getOpenAccounts: jest.fn(), + getAssetDescription: jest.fn(), + getFundamentals: jest.fn(), + getTradingStatus: jest.fn(), + getCandles: jest.fn(), + getBondCoupons: jest.fn(), + }; + const moex = { bondYield: jest.fn() }; + const ratings = { rating: jest.fn() }; + const share = { + uid: 'share', + ticker: 'SBER', + name: 'Synthetic share', + isin: 'SHARE', + classCode: 'TQBR', + instrumentKind: 'INSTRUMENT_TYPE_SHARE', + apiTradeAvailableFlag: true, + }; + const bond = { + uid: 'bond', + ticker: 'BOND', + name: 'Synthetic bond', + isin: 'BOND', + classCode: 'TQOB', + instrumentKind: 'INSTRUMENT_TYPE_BOND', + }; + const auth = (user = 'owner') => + `Bearer ${new JwtService().sign({ sub: user, type: 'access' }, { secret: process.env.JWT_SECRET })}`; + const get = (path = 'stocks?query=SBER', user = 'owner') => + request(app.getHttpServer()) + .get(`/api/v1/${path}`) + .set('Authorization', auth(user)) + .timeout(1500); + const read = async (observe = true, user = 'owner') => + ( + await get( + `stocks?query=SBER${observe ? '&sync=observe' : ''}`, + user, + ).expect(200) + ).body as StockSearchResourceDto; + async function settled() { + for (let i = 0; i < 400; i++) { + await delay(5); + if ( + !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) + ) { + await delay(10); + if ( + !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) + ) + return; + } + } + throw new Error('Catalog sources did not settle'); + } + beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TbankClient) + .useValue(upstream) + .overrideProvider(MoexClient) + .useValue(moex) + .overrideProvider(NcrRatingsClient) + .useValue(ratings) + .compile(); + app = module.createNestApplication(); + configureApp(app); + await app.init(); + await app.listen(0, '127.0.0.1'); + prisma = app.get(PrismaService); + for (const id of ['owner', 'other']) + await prisma.user.create({ + data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, + }); + }); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + await prisma.tbankInstrumentLookup.deleteMany(); + await prisma.tbankInstrument.deleteMany(); + await prisma.tbankCatalogDiscovery.deleteMany(); + jest.resetAllMocks(); + upstream.getAssetDescription.mockResolvedValue('Synthetic company'); + upstream.getFundamentals.mockResolvedValue({ + assetUid: 'asset', + currency: 'rub', + peRatioTtm: 6, + netIncomeTtm: 10, + }); + upstream.getTradingStatus.mockResolvedValue( + 'SECURITY_TRADING_STATUS_NORMAL_TRADING', + ); + upstream.getCandles.mockResolvedValue([]); + upstream.getBondCoupons.mockResolvedValue([]); + moex.bondYield.mockResolvedValue(null); + ratings.rating.mockResolvedValue(null); + upstream.findShares.mockResolvedValue([share]); + upstream.findBonds.mockResolvedValue([bond]); + upstream.getShare.mockImplementation((_token: string, uid: string) => + Promise.resolve({ + ...share, + uid, + currency: 'rub', + sector: 'financial', + assetUid: 'asset', + lot: 10, + }), + ); + upstream.getBond.mockResolvedValue({ + ...bond, + currency: 'rub', + nominal: { currency: 'rub', units: '800' }, + aciValue: { currency: 'rub', units: '12', nano: 1 }, + }); + upstream.getLastPrices.mockImplementation( + (_token: string, uids: string[]) => + Promise.resolve( + uids.map((uid) => ({ + instrumentUid: uid, + price: { units: '99', nano: 100000001 }, + time: { seconds: '1700000000' }, + })), + ), + ); + for (const userId of ['owner', 'other']) + await prisma.tbankConnection.create({ + data: { + userId, + revision: `${userId}-revision`, + encryptedToken: app + .get(TbankTokenService) + .encrypt(`synthetic-${userId}`), + connectedAt: new Date(), + }, + }); + }); + afterAll(async () => app?.close()); + + it('returns missing without waiting for discovery, then partial while enrichment is blocked; observe never launches work', async () => { + const found = deferred<(typeof share)[]>(); + const detail = deferred(); + upstream.findShares.mockReturnValueOnce(found.promise); + upstream.getShare.mockReturnValueOnce(detail.promise); + expect((await read()).data).toBeNull(); + expect(upstream.findShares).not.toHaveBeenCalled(); + const cold = await read(false); + expect(cold.data).toBeNull(); + expect(cold.meta.availability).toBe('missing'); + found.resolve([share]); + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + await delay(5); + const partial = await read(); + expect(partial.data?.[0].instrumentUid).toBe('share'); + expect(partial.meta.availability).toBe('partial'); + expect(partial.data?.[0].price).toBeNull(); + expect(partial.meta.resourceKey).toBe(cold.meta.resourceKey); + expect( + partial.meta.dependencies?.some( + (dependency) => dependency.sync.state === 'running', + ), + ).toBe(true); + detail.resolve({ ...share }); + await settled(); + expect((await read()).meta.availability).toBe('complete'); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + }); + it('reuses shared success after SQLite reopen and across users without decrypt, GetAccounts or broker RPC', async () => { + await read(false); + await settled(); + const before = await read(); + await prisma.$disconnect(); + const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt'); + const after = await read(false, 'other'); + expect(after.data).toEqual(before.data); + expect(after.data?.[0].price).toEqual({ + currency: 'RUB', + value: '99.100000001', + }); + expect( + after.meta.dependencies?.every( + (dependency) => dependency.sync.id === null, + ), + ).toBe(true); + expect(decrypt).not.toHaveBeenCalled(); + decrypt.mockRestore(); + const metadata = await app + .get(InstrumentMetadataService) + .read('other', { instrumentUid: 'share' }); + expect(metadata?.complete).toBe(true); + expect(metadata?.data?.name).toBe(share.name); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + expect(upstream.getShare).toHaveBeenCalledTimes(1); + expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + }); + it('uses one price batch for thirty deduplicated selected UIDs, matching reversed prices by UID', async () => { + upstream.findShares.mockResolvedValue([ + { ...share, uid: 'duplicate', apiTradeAvailableFlag: false }, + ...Array.from({ length: 35 }, (_, i) => ({ + ...share, + uid: `s${String(i).padStart(2, '0')}`, + isin: i ? undefined : 'SHARE', + })), + ]); + upstream.getLastPrices.mockImplementation( + (_token: string, uids: string[]) => + Promise.resolve( + uids.toReversed().map((uid, i) => ({ + instrumentUid: uid, + price: { units: String(i + 1) }, + })), + ), + ); + await read(false); + await settled(); + const response = await read(); + expect(response.data).toHaveLength(30); + expect(response.data?.[0].instrumentUid).toBe('s00'); + expect(response.data?.[0].price?.value).toBe('30'); + expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); + expect(upstream.getShare).toHaveBeenCalledTimes(30); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + }); + it('keeps confirmed empty separate from unknown queries and does not enrich an empty result', async () => { + upstream.findShares.mockResolvedValue([]); + expect((await read(false)).data).toBeNull(); + await settled(); + expect((await read()).data).toEqual([]); + const other = (await get('stocks?query=GAZP&sync=observe').expect(200)) + .body as StockSearchResourceDto; + expect(other.data).toBeNull(); + expect(other.meta.availability).toBe('missing'); + expect(upstream.getShare).not.toHaveBeenCalled(); + expect(upstream.getLastPrices).not.toHaveBeenCalled(); + }); + it('retains catalog identity on enrichment failures, rejects mismatched detail UIDs and never returns fake prices', async () => { + upstream.getShare.mockResolvedValue({ ...share, uid: 'another' }); + upstream.getLastPrices.mockRejectedValue(new Error('outage')); + await read(false); + await settled(); + const result = await read(); + expect(result.data?.[0]).toMatchObject({ + instrumentUid: 'share', + ticker: 'SBER', + price: null, + }); + expect(result.meta.availability).toBe('partial'); + expect( + result.meta.dependencies?.filter((item) => item.sync.state === 'failed'), + ).toHaveLength(2); + await read(); + expect(upstream.getShare).toHaveBeenCalledTimes(1); + expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); + }); + it('keeps bond percent separate from money and preserves exact nominal and ACI', async () => { + await get('bonds?query=BOND').expect(200); + await settled(); + const result = (await get('bonds?query=BOND&sync=observe').expect(200)) + .body as BondSearchResourceDto; + expect(result.data?.[0]).toMatchObject({ + pricePercent: '99.100000001', + nominal: { currency: 'RUB', value: '800' }, + aci: { currency: 'RUB', value: '12.000000001' }, + }); + expect(result.data?.[0]).not.toHaveProperty('price'); + expect(upstream.findShares).not.toHaveBeenCalled(); + }); + it('refreshes only prices at the minute boundary and preserves old prices after failure', async () => { + await read(false); + await settled(); + const before = await read(); + await prisma.tbankInstrumentSource.updateMany({ + where: { kind: 'last-price' }, + data: { fetchedAt: new Date(Date.now() - 60_000) }, + }); + upstream.getLastPrices.mockRejectedValue(new Error('outage')); + await read(); + expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); + await read(false); + await settled(); + const failed = await read(); + expect(failed.data).toEqual(before.data); + expect(failed.meta.freshness).toBe('stale'); + expect(upstream.getShare).toHaveBeenCalledTimes(1); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + expect(upstream.getLastPrices).toHaveBeenCalledTimes(2); + }); + it('coalesces sources across different queries and users, with private handles and no private fields in shared rows', async () => { + const detail = deferred(); + upstream.getShare.mockReturnValueOnce(detail.promise); + await read(false); + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + await delay(5); + await get('stocks?query=OTHER', 'other').expect(200); + for ( + let i = 0; + i < 100 && + (await prisma.tbankSyncWork.count({ where: { userId: 'other' } })) < 3; + i++ + ) + await delay(5); + detail.resolve({ + ...share, + currency: 'rub', + quantity: 'private', + estimatedAmount: 'private', + } as typeof share); + await settled(); + expect(upstream.getShare).toHaveBeenCalledTimes(1); + expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); + const owner = await read(); + const other = (await get('stocks?query=OTHER&sync=observe', 'other')) + .body as StockSearchResourceDto; + expect(owner.meta.dependencies?.at(-1)?.sync.id).not.toBe( + other.meta.dependencies?.at(-1)?.sync.id, + ); + expect( + JSON.stringify(await prisma.tbankInstrumentSource.findMany()), + ).not.toMatch(/quantity|estimatedAmount|private|encryptedToken/); + }); + it.each(['delete', 'replace'])( + 'discards delayed enrichment after connection %s', + async (action) => { + const detail = deferred(); + upstream.getShare.mockReturnValueOnce(detail.promise); + await read(false); + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + await delay(5); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + if (action === 'replace') + await prisma.tbankConnection.create({ + data: { + userId: 'owner', + revision: 'new', + encryptedToken: app.get(TbankTokenService).encrypt('new'), + connectedAt: new Date(), + }, + }); + detail.resolve(share); + await settled(); + expect( + await prisma.tbankInstrumentSource.count({ where: { kind: 'detail' } }), + ).toBe(0); + expect((await read(true, 'other')).data?.[0].instrumentUid).toBe('share'); + }, + ); + it('recovers abandoned enrichment only on an ordinary request, and fences the superseded attempt', async () => { + await read(false); + await settled(); + await prisma.tbankInstrumentSource.deleteMany({ + where: { kind: 'detail' }, + }); + const repository = app.get(CatalogDiscoveryRepository); + const input = { + instrumentType: 'share', + uid: 'share', + kind: 'detail', + } as const; + const old = await repository.reserve( + { userId: 'owner', revision: 'owner-revision' }, + input, + 'old-process', + new Set(), + ); + assert(old); + await read(); + expect(upstream.getShare).toHaveBeenCalledTimes(1); + await read(false); + await settled(); + expect(upstream.getShare).toHaveBeenCalledTimes(2); + expect( + await repository.publishSource( + { + userId: 'owner', + revision: 'owner-revision', + id: old.work.id, + attemptId: old.work.attemptId, + }, + input, + { instrument: { ...share, name: 'Late' } }, + new Date(), + ), + ).toBe(false); + }); + it('preserves fresh successful sources on failed force and isolates credential failures', async () => { + await read(false); + await settled(); + const before = await read(); + upstream.getShare.mockRejectedValue(new Error('outage')); + upstream.getLastPrices.mockRejectedValue(new Error('outage')); + await app + .get(CatalogEnrichmentService) + .requestMany('owner', 'owner-revision', 'share', ['share'], true); + await settled(); + const after = await read(); + expect(after.data).toEqual(before.data); + expect(after.meta.freshness).toBe('fresh'); + expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); + expect( + (await read(true, 'other')).meta.dependencies?.every( + (item) => item.sync.errorCode === null, + ), + ).toBe(true); + }); + it('rejects every price in a batch if its initiating attempt is superseded during the RPC', async () => { + const prices = + deferred<{ instrumentUid: string; price: { units: string } }[]>(); + upstream.findShares.mockResolvedValue([ + share, + { ...share, uid: 'second', isin: 'SECOND' }, + ]); + upstream.getLastPrices.mockReturnValueOnce(prices.promise); + await read(false); + for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) + await delay(5); + const initiating = await prisma.tbankSyncWork.findFirstOrThrow({ + where: { userId: 'owner', scopeKey: { contains: 'last-price' } }, + orderBy: { createdAt: 'asc' }, + }); + await prisma.tbankSyncWork.update({ + where: { id: initiating.id }, + data: { attemptId: 'superseding-attempt', state: 'failed' }, + }); + prices.resolve([ + { instrumentUid: 'share', price: { units: '100' } }, + { instrumentUid: 'second', price: { units: '200' } }, + ]); + await settled(); + expect( + await prisma.tbankInstrumentSource.count({ + where: { kind: 'last-price' }, + }), + ).toBe(0); + expect((await read()).data?.every((item) => item.price === null)).toBe( + true, + ); + }); + it('refreshes stale detail independently of still-fresh prices and discovery', async () => { + await read(false); + await settled(); + await prisma.tbankInstrumentSource.updateMany({ + where: { kind: 'detail' }, + data: { fetchedAt: new Date(Date.now() - 86_400_000) }, + }); + await read(); + expect(upstream.getShare).toHaveBeenCalledTimes(1); + await read(false); + await settled(); + expect(upstream.getShare).toHaveBeenCalledTimes(2); + expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); + expect(upstream.findShares).toHaveBeenCalledTimes(1); + }); + it('reads exact UID locally without waiting, batches twenty reads and keeps UID accessible when alias is ambiguous', async () => { + const uid = '00000000-0000-4000-8000-000000000001'; + const blocked = deferred(); + upstream.getShare.mockReturnValueOnce(blocked.promise); + upstream.findShares.mockResolvedValue([ + { ...share, uid }, + { ...share, uid: 'other-listing', isin: 'OTHER' }, + ]); + expect( + ((await get(`stocks/${uid}?sync=observe`)).body as StockResourceDto).data, + ).toBeNull(); + expect(upstream.getShare).not.toHaveBeenCalled(); + const reads = await Promise.all( + Array.from({ length: 20 }, () => get(`stocks/${uid}`).expect(200)), + ); + expect( + reads.every( + (response) => (response.body as StockResourceDto).data === null, + ), + ).toBe(true); + expect(upstream.getLastPrices).not.toHaveBeenCalled(); + blocked.resolve({ ...share, uid }); + await settled(); + const first = (await get(`stocks/${uid}`).expect(200)) + .body as StockResourceDto; + expect(first.data?.instrumentUid).toBe(uid); + await settled(); + const exact = (await get(`stocks/${uid}?sync=observe`).expect(200)) + .body as StockResourceDto; + expect(exact.data?.slug).toBe(uid); + await get('stocks/by-ticker/SBER?sync=observe').expect(409); + expect(upstream.getShare).toHaveBeenCalledTimes(1); + }); + it('resolves exact ticker before ISIN selection and does not mistake search completeness for ticker coverage', async () => { + const uid = '00000000-0000-4000-8000-000000000002'; + upstream.findShares.mockResolvedValue([ + { ...share, uid: 'preferred', ticker: 'SBERP' }, + { ...share, uid, ticker: 'SBER', apiTradeAvailableFlag: false }, + ]); + await read(false); + await settled(); + expect( + ( + (await get('stocks/by-ticker/SBER?sync=observe')) + .body as StockResourceDto + ).data, + ).toBeNull(); + const cold = (await get('stocks/by-ticker/sber').expect(200)) + .body as StockResourceDto; + expect(cold.data).toBeNull(); + await settled(); + const resolved = ( + await get('stocks/by-ticker/SBER?sync=observe', 'other').expect(200) + ).body as StockResourceDto; + expect(resolved.data).toMatchObject({ instrumentUid: uid, slug: 'SBER' }); + expect(upstream.findShares).toHaveBeenCalledTimes(2); + upstream.findShares.mockResolvedValue([]); + await get('stocks/by-ticker/NONE').expect(200); + await settled(); + await get('stocks/by-ticker/NONE?sync=observe').expect(404); + }); + it('keeps known typed UID misses separate from unknown and from generic UID lookup', async () => { + const uid = '00000000-0000-4000-8000-000000000003'; + upstream.getBond.mockResolvedValue(null); + const cold = (await get(`bonds/${uid}`).expect(200)) + .body as StockResourceDto; + expect(cold.data).toBeNull(); + await settled(); + await get(`bonds/${uid}?sync=observe`).expect(404); + expect( + await prisma.tbankInstrumentLookup.count({ where: { identifier: uid } }), + ).toBe(0); + expect(upstream.getLastPrices).not.toHaveBeenCalled(); + }); + it('loads company and fundamentals locally, shares successes and keeps old metrics on failed refresh', async () => { + const uid = '00000000-0000-4000-8000-000000000011'; + const company = deferred(); + upstream.getAssetDescription.mockReturnValueOnce(company.promise); + const cold = (await get(`stocks/${uid}/company`).expect(200)) + .body as StockCompanyResourceDto; + expect(cold.data).toBeNull(); + for ( + let i = 0; + i < 100 && !upstream.getAssetDescription.mock.calls.length; + i++ + ) + await delay(5); + const partial = ( + await get(`stocks/${uid}/company?sync=observe`).expect(200) + ).body as StockCompanyResourceDto; + expect(partial.meta.availability).toBe('partial'); + company.resolve('Synthetic company'); + await settled(); + const warm = (await get(`stocks/${uid}/company`, 'other').expect(200)) + .body as StockCompanyResourceDto; + expect(warm.data?.description).toBe('Synthetic company'); + expect(warm.data?.metrics.map((item) => item.key)).toEqual([ + 'peRatioTtm', + 'netIncomeTtm', + ]); + expect(upstream.getFundamentals).toHaveBeenCalledTimes(1); + expect(upstream.getAssetDescription).toHaveBeenCalledTimes(1); + upstream.getFundamentals.mockRejectedValue(new Error('outage')); + upstream.getAssetDescription.mockRejectedValue(new Error('outage')); + await app + .get(CatalogEnrichmentService) + .requestMany('owner', 'owner-revision', 'share', [uid], true, [ + 'description', + 'fundamentals', + ]); + await settled(); + const failed = (await get(`stocks/${uid}/company?sync=observe`).expect(200)) + .body as StockCompanyResourceDto; + expect(failed.data?.metrics).toEqual(warm.data?.metrics); + expect(failed.data?.description).toBe(warm.data?.description); + expect(failed.data?.observedAt).toBe(warm.data?.observedAt); + expect(failed.data?.unavailable).toEqual(['description', 'fundamentals']); + }); + it('reads stock quotes without waiting for blocked prices and reuses quote components across users', async () => { + const uid = '00000000-0000-4000-8000-000000000012'; + const prices = deferred< + { + instrumentUid: string; + price: { units: string }; + time: { seconds: string }; + }[] + >(); + upstream.getLastPrices.mockReturnValueOnce(prices.promise); + upstream.getCandles.mockResolvedValue([ + { + time: { seconds: '1699833600' }, + close: { units: '90' }, + isComplete: true, + }, + ]); + const cold = (await get(`stocks/${uid}/quote`).expect(200)) + .body as StockQuoteResourceDto; + expect(cold.meta.availability).toBe('missing'); + for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) + await delay(5); + const pending = (await get(`stocks/${uid}/quote?sync=observe`).expect(200)) + .body as StockQuoteResourceDto; + expect(pending.data?.price).toBeNull(); + prices.resolve([ + { + instrumentUid: uid, + price: { units: '99' }, + time: { seconds: '1700000000' }, + }, + ]); + await settled(); + const warm = (await get(`stocks/${uid}/quote`, 'other').expect(200)) + .body as StockQuoteResourceDto; + expect(warm.data).toMatchObject({ + price: { currency: 'RUB', value: '99' }, + previousClose: { currency: 'RUB', value: '90' }, + change: { currency: 'RUB', value: '9' }, + changePercent: '10', + }); + expect(upstream.getLastPrices).toHaveBeenCalledTimes(1); + expect(upstream.getTradingStatus).toHaveBeenCalledTimes(1); + expect(upstream.getCandles).toHaveBeenCalledTimes(1); + expect( + await prisma.tbankCandle.count({ where: { instrumentUid: uid } }), + ).toBe(1); + await app + .get(CatalogEnrichmentService) + .requestMany('owner', 'owner-revision', 'share', [uid], true, [ + 'comparison', + ]); + await settled(); + expect(upstream.getCandles).toHaveBeenCalledTimes(1); + }); + it('keeps bond quote money exact and coupon metrics available when optional MOEX/NCR fail', async () => { + const uid = '00000000-0000-4000-8000-000000000013'; + upstream.getBond.mockResolvedValue({ + ...bond, + uid, + currency: 'rub', + nominal: { currency: 'rub', units: '800' }, + aciValue: { currency: 'rub', units: '12', nano: 1 }, + riskLevel: 'RISK_LEVEL_LOW', + }); + upstream.getBondCoupons.mockResolvedValue([ + { + couponDate: { + seconds: String(Math.floor(Date.now() / 1000) + 86400 * 7), + }, + payOneBond: { currency: 'rub', units: '30' }, + couponPeriod: 90, + }, + ]); + moex.bondYield.mockRejectedValue(new Error('MOEX outage')); + ratings.rating.mockRejectedValue(new Error('NCR outage')); + await get(`bonds/${uid}/metrics`).expect(200); + await settled(); + const metrics = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200)) + .body as BondMetricsResourceDto; + expect(metrics.meta.availability).toBe('partial'); + expect(metrics.data).toMatchObject({ + coupon: { currency: 'RUB', value: '30' }, + creditRating: null, + effectiveYield: null, + }); + expect(metrics.data?.couponYieldPercent).not.toBeNull(); + expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'BOND', 'TQOB'); + expect(ratings.rating).toHaveBeenCalledWith('BOND'); + const quote = (await get(`bonds/${uid}/quote?sync=observe`).expect(200)) + .body as BondQuoteResourceDto; + expect(quote.data).toMatchObject({ + pricePercent: '99.100000001', + cleanPrice: { currency: 'RUB', value: '792.800000008' }, + dirtyPrice: { currency: 'RUB', value: '804.800000009' }, + }); + expect( + await prisma.tbankInstrumentSource.count({ where: { source: 'moex' } }), + ).toBe(0); + expect(upstream.getBond).toHaveBeenCalledTimes(1); + }); + it('serves saved candle envelopes without waiting for RPC and never runs observe work', async () => { + const uid = '00000000-0000-4000-8000-000000000017'; + upstream.getShare.mockResolvedValue({ + ...share, + uid, + currency: 'rub', + first_1dayCandleDate: { seconds: '1577836800' }, + }); + const blocked = deferred(); + upstream.getCandles.mockReturnValueOnce(blocked.promise); + const cold = (await get(`stocks/${uid}/candles?period=all`).expect(200)) + .body as StockCandlesResourceDto; + expect(cold.data).toBeNull(); + await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200); + blocked.resolve([]); + await settled(); + const warm = ( + await get(`stocks/${uid}/candles?period=all&sync=observe`).expect(200) + ).body as StockCandlesResourceDto; + expect(warm.data).toMatchObject({ + candles: [], + currency: 'RUB', + historyFrom: '2020-01-01T00:00:00.000Z', + }); + expect(warm.meta.availability).toBe('complete'); + const count = upstream.getCandles.mock.calls.length; + await get(`stocks/${uid}/candles?period=all`, 'other').expect(200); + expect(upstream.getCandles).toHaveBeenCalledTimes(count); + await get(`stocks/${uid}/candles?sync=invalid`).expect(400); + }); + + it('keeps unknown all-history bounds unavailable instead of confirming empty history', async () => { + const uid = '00000000-0000-4000-8000-000000000018'; + await get(`stocks/${uid}/candles?period=all`).expect(200); + await settled(); + const response = await get( + `stocks/${uid}/candles?period=all&sync=observe`, + ).expect(503); + expect(JSON.stringify(response.body)).toContain( + 'STOCK_HISTORY_START_UNAVAILABLE', + ); + expect(upstream.getCandles).not.toHaveBeenCalled(); + expect(await prisma.tbankCandleCoverage.count()).toBe(0); + }); + + it('coalesces a second user arriving while SQLite publication is pending', async () => { + const uid = '00000000-0000-4000-8000-000000000016'; + const repository = app.get(CatalogDiscoveryRepository); + const original = repository.publishSource.bind(repository); + const entered = deferred(); + const release = deferred(); + const publish = jest + .spyOn(repository, 'publishSource') + .mockImplementationOnce(async (...args) => { + entered.resolve(); + await release.promise; + return original(...args); + }); + const service = app.get(CatalogEnrichmentService); + try { + await service.requestMany( + 'owner', + 'owner-revision', + 'share', + [uid], + false, + ['detail'], + ); + await entered.promise; + await service.requestMany( + 'other', + 'other-revision', + 'share', + [uid], + false, + ['detail'], + ); + release.resolve(); + await settled(); + expect(upstream.getShare).toHaveBeenCalledTimes(1); + const work = await prisma.tbankSyncWork.findMany(); + expect(work).toHaveLength(2); + expect(work.every((row) => row.state === 'complete')).toBe(true); + } finally { + release.resolve(); + publish.mockRestore(); + } + }); + + it('persists MOEX/NCR independently and retains their exact values after failed force', async () => { + const uid = '00000000-0000-4000-8000-000000000015'; + upstream.getBond.mockResolvedValue({ ...bond, uid }); + const effectiveYield = { + value: '13.123456789', + pricePercent: '99.100000001', + date: '2028-01-01', + dateType: 'MATDATE', + tradeAt: '2026-09-25T10:00:00.000Z', + }; + const creditRating = { + agency: 'NCR', + value: 'AAA.ru', + status: 'active', + date: '2026-09-25', + sourceUrl: 'https://ratings.ru/ratings/issues/', + observedAt: '2026-09-25T10:00:00.000Z', + }; + moex.bondYield.mockResolvedValue(effectiveYield); + ratings.rating.mockResolvedValue(creditRating); + await get(`bonds/${uid}/metrics`).expect(200); + await settled(); + const before = (await get(`bonds/${uid}/metrics`, 'other').expect(200)) + .body as BondMetricsResourceDto; + expect(before.data).toMatchObject({ effectiveYield, creditRating }); + expect(moex.bondYield).toHaveBeenCalledTimes(1); + expect(ratings.rating).toHaveBeenCalledTimes(1); + const saved = await prisma.tbankInstrumentSource.findMany({ + where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } }, + }); + expect(saved).toHaveLength(2); + await prisma.$disconnect(); + moex.bondYield.mockRejectedValue(new Error('MOEX outage')); + ratings.rating.mockRejectedValue(new Error('NCR outage')); + await app + .get(CatalogEnrichmentService) + .requestMany('owner', 'owner-revision', 'bond', [uid], true, [ + 'yield', + 'rating', + ]); + await settled(); + const after = (await get(`bonds/${uid}/metrics?sync=observe`).expect(200)) + .body as BondMetricsResourceDto; + expect(after.data).toMatchObject({ effectiveYield, creditRating }); + expect( + await prisma.tbankInstrumentSource.findMany({ + where: { instrumentUid: uid, source: { in: ['moex', 'ncr'] } }, + }), + ).toEqual(saved); + }); + + it('discards a company response from a deleted connection and does not disclose its failure to another user', async () => { + const uid = '00000000-0000-4000-8000-000000000014'; + const company = deferred(); + upstream.getAssetDescription.mockReturnValueOnce(company.promise); + await get(`stocks/${uid}/company`).expect(200); + for ( + let i = 0; + i < 100 && !upstream.getAssetDescription.mock.calls.length; + i++ + ) + await delay(5); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + company.resolve('Late company'); + await settled(); + expect( + await prisma.tbankInstrumentSource.count({ + where: { kind: 'description' }, + }), + ).toBe(0); + const other = ( + await get(`stocks/${uid}/company?sync=observe`, 'other').expect(200) + ).body as StockCompanyResourceDto; + expect( + other.meta.dependencies?.every((item) => item.sync.errorCode === null), + ).toBe(true); + }); + it('enforces access and query validation without broker calls', async () => { + await request(app.getHttpServer()) + .get('/api/v1/stocks?query=SBER') + .expect(401); + await get('stocks?query=x').expect(400); + await get('bonds?query=BOND&sync=force').expect(400); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + await get().expect(409); + await prisma.tbankConnection.update({ + where: { userId: 'other' }, + data: { invalidToken: true }, + }); + await get('stocks?query=SBER', 'other').expect(400); + expect(upstream.findShares).not.toHaveBeenCalled(); + expect(upstream.findBonds).not.toHaveBeenCalled(); + }); +}); +function deferred() { + let resolve: (value: T) => void = () => { + throw new Error('Uninitialized deferred'); + }; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} diff --git a/test/tbank-connection.e2e-spec.ts b/test/tbank-connection.e2e-spec.ts new file mode 100644 index 0000000..c23e12f --- /dev/null +++ b/test/tbank-connection.e2e-spec.ts @@ -0,0 +1,263 @@ +import { INestApplication, NotFoundException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; + +describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { + let app: INestApplication; + let prisma: PrismaService; + const upstream = { validateToken: jest.fn(), getOpenAccounts: jest.fn() }; + const jwt = (owner: string) => + new JwtService().sign( + { sub: owner, type: 'access' }, + { secret: process.env.JWT_SECRET }, + ); + const put = (token = 'synthetic-token', owner = 'owner') => + request(app.getHttpServer()) + .put('/api/v1/tbank-connection') + .set('Authorization', `Bearer ${jwt(owner)}`) + .send({ token }); + + beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TbankClient) + .useValue(upstream) + .compile(); + app = module.createNestApplication(); + configureApp(app); + await app.init(); + await app.listen(0, '127.0.0.1'); + prisma = app.get(PrismaService); + for (const id of ['owner', 'other']) + await prisma.user.create({ + data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, + }); + }); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + jest.clearAllMocks(); + upstream.validateToken.mockResolvedValue([ + { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: ' Synthetic ' }, + { id: 'unsupported', type: 'ACCOUNT_TYPE_UNSPECIFIED', name: 'Ignored' }, + ]); + }); + afterAll(async () => app?.close()); + + it('publishes validated membership once, checks local ownership without RPC or decrypt, and preserves failed replacement', async () => { + await request(app.getHttpServer()) + .put('/api/v1/tbank-connection') + .send({ token: 'synthetic' }) + .expect(401); + expect(upstream.validateToken).not.toHaveBeenCalled(); + const response = await put(' synthetic-token ').expect(200); + expect(Object.keys(response.body as object).sort()).toEqual([ + 'connected', + 'connectedAt', + ]); + const saved = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + expect(app.get(TbankTokenService).decrypt(saved.encryptedToken)).toBe( + 'synthetic-token', + ); + const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt'); + const access = app.get(TbankAccessService); + const local = await access.local('owner', 'account'); + expect( + local.accounts.map((account) => [account.accountId, account.name]), + ).toEqual([['account', 'Synthetic']]); + expect(local.membershipAt).not.toBeNull(); + expect(decrypt).not.toHaveBeenCalled(); + decrypt.mockRestore(); + expect(upstream.validateToken).toHaveBeenCalledTimes(1); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + await expect(access.local('owner', 'unsupported')).rejects.toBeInstanceOf( + NotFoundException, + ); + await put('other-token', 'other').expect(200); + await prisma.tbankAccount.deleteMany({ where: { userId: 'other' } }); + await expect(access.local('other', 'account')).rejects.toBeInstanceOf( + NotFoundException, + ); + await prisma.tbankConnection.update({ + where: { userId: 'owner' }, + data: { membershipAt: new Date(0) }, + }); + expect((await access.local('owner', 'account')).accounts).toHaveLength(1); + upstream.validateToken.mockRejectedValueOnce( + new Error('INVALID_TBANK_TOKEN'), + ); + expect((await put('invalid').expect(400)).body).toMatchObject({ + code: 'INVALID_TBANK_TOKEN', + }); + expect( + ( + await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }) + ).revision, + ).toBe(saved.revision); + expect((await access.local('owner', 'account')).accounts).toHaveLength(1); + }); + + it('rotates even the same token atomically, removes private rows and fences late writes after replace/delete', async () => { + await put().expect(200); + const old = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + const operation = { + userId: 'owner', + revision: old.revision, + accountId: 'account', + brokerOperationId: 'operation', + occurredAt: new Date(), + category: 'income', + payload: { payment: { value: '1.000000001', currency: 'RUB' } }, + }; + await prisma.tbankOperation.create({ data: operation }); + upstream.validateToken.mockResolvedValueOnce([ + { id: 'duplicate', type: 'ACCOUNT_TYPE_TINKOFF', name: 'First' }, + { id: 'duplicate', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Second' }, + ]); + await put('candidate-with-invalid-membership').expect(500); + expect( + ( + await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }) + ).revision, + ).toBe(old.revision); + expect(await prisma.tbankOperation.count()).toBe(1); + await put().expect(200); + const replacement = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + expect(replacement.revision).not.toBe(old.revision); + expect(replacement.encryptedToken).not.toBe(old.encryptedToken); + expect(await prisma.tbankOperation.count()).toBe(0); + await expect( + prisma.tbankOperation.create({ data: operation }), + ).rejects.toMatchObject({ code: 'P2003' }); + for (let index = 0; index < 2; index += 1) + await request(app.getHttpServer()) + .delete('/api/v1/tbank-connection') + .set('Authorization', `Bearer ${jwt('owner')}`) + .expect(204); + await expect( + prisma.tbankOperation.create({ + data: { ...operation, revision: replacement.revision }, + }), + ).rejects.toMatchObject({ code: 'P2003' }); + await expect( + app.get(TbankAccessService).local('owner'), + ).rejects.toMatchObject({ response: { code: 'TBANK_NOT_CONNECTED' } }); + }); + + it('distinguishes unknown membership, complete empty membership and known invalid credentials', async () => { + await prisma.tbankConnection.create({ + data: { + userId: 'owner', + encryptedToken: 'never-decrypted', + connectedAt: new Date(), + }, + }); + const access = app.get(TbankAccessService); + expect((await access.local('owner', 'unknown')).membershipAt).toBeNull(); + upstream.validateToken.mockResolvedValueOnce([]); + await put().expect(200); + expect((await access.local('owner')).accounts).toEqual([]); + await expect(access.local('owner', 'unknown')).rejects.toBeInstanceOf( + NotFoundException, + ); + await prisma.tbankConnection.update({ + where: { userId: 'owner' }, + data: { invalidToken: true }, + }); + await expect(access.local('owner')).rejects.toMatchObject({ + response: { code: 'INVALID_TBANK_TOKEN' }, + }); + await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts') + .set('Authorization', `Bearer ${jwt('owner')}`) + .expect(400) + .expect((response) => { + expect(response.body).toMatchObject({ code: 'INVALID_TBANK_TOKEN' }); + }); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + }); + + it('publishes membership atomically without losing retained data and rejects superseded attempts', async () => { + await put().expect(200); + const access = app.get(TbankAccessService); + const connection = await access.local('owner'); + const scope = { userId: 'owner', revision: connection.revision }; + const attempt = { ...scope, id: 'membership', attemptId: 'first' }; + await prisma.tbankSyncWork.create({ + data: { + ...attempt, + scopeKey: 'membership', + state: 'running', + intent: {}, + progress: {}, + processRunId: 'test', + priority: 0, + }, + }); + await prisma.tbankPortfolio.create({ + data: { + ...scope, + accountId: 'account', + payload: { total: '123.000000001' }, + fetchedAt: new Date(0), + }, + }); + const accounts = [ + { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Synthetic' }, + ]; + expect( + await access.publishMembership(attempt, accounts, new Date(1000)), + ).toBe(true); + expect((await access.local('owner')).membershipRevision).toBe( + connection.membershipRevision, + ); + expect(await prisma.tbankPortfolio.count()).toBe(1); + await prisma.tbankSyncWork.update({ + where: { id: attempt.id }, + data: { attemptId: 'second' }, + }); + expect(await access.publishMembership(attempt, [], new Date(2000))).toBe( + false, + ); + expect(await access.rejectMembershipToken(attempt)).toBe(false); + expect((await access.local('owner')).membershipAt).toEqual(new Date(1000)); + const current = { ...attempt, attemptId: 'second' }; + await expect( + access.publishMembership( + current, + [...accounts, ...accounts], + new Date(3000), + ), + ).rejects.toThrow(); + expect(await prisma.tbankPortfolio.count()).toBe(1); + expect(await access.publishMembership(current, [], new Date(4000))).toBe( + true, + ); + expect(await prisma.tbankPortfolio.count()).toBe(0); + const empty = await access.local('owner'); + expect(empty.accounts).toEqual([]); + expect(empty.membershipRevision).toBe(connection.membershipRevision + 1); + await put('replacement').expect(200); + expect(await access.publishMembership(current, [], new Date(5000))).toBe( + false, + ); + expect(await access.rejectMembershipToken(current)).toBe(false); + expect((await access.local('owner')).accounts).toHaveLength(1); + }); +}); diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts new file mode 100644 index 0000000..617b83b --- /dev/null +++ b/test/tbank-income-local.e2e-spec.ts @@ -0,0 +1,272 @@ +import assert from 'node:assert/strict'; +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { moscowDate } from '../src/modules/investments/investment-calendar'; +import { + IncomeAnalyticsResourceDto, + IncomeSummaryResourceDto, +} from '../src/modules/investments/income/income-analytics.dto'; +import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; + +// Financial proof uses real persisted rows/coverage and the configured HTTP boundary. +describe('local income completeness and exact money (SQLite)', () => { + let app: INestApplication; + let prisma: PrismaService; + let revision: string; + const upstream = { + validateToken: jest.fn(), + getOpenAccounts: jest.fn(), + getOperationsByCursor: jest.fn(), + }; + const from = new Date('2024-12-31T21:00:00Z'); + const to = new Date('2025-01-31T21:00:00Z'); + const authorization = `Bearer ${new JwtService().sign({ sub: 'income-owner', type: 'access' }, { secret: process.env.JWT_SECRET })}`; + const get = ( + account = 'all', + period = 'from=2025-01-01&to=2025-01-31', + observe = true, + ) => + request(app.getHttpServer()) + .get( + `/api/v1/brokerage-accounts/${account}/income-analytics?${period}${observe ? '&sync=observe' : ''}`, + ) + .set('Authorization', authorization) + .timeout(2000); + const read = async (account = 'all', period?: string) => + (await get(account, period).expect(200)).body as IncomeAnalyticsResourceDto; + const coverage = async (accountId: string, start = from, end = to) => + prisma.tbankOperationCoverage.create({ + data: { + userId: 'income-owner', + revision, + accountId, + from: start, + to: end, + asOf: end, + fetchedAt: new Date('2025-02-01Z'), + }, + }); + beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TbankClient) + .useValue(upstream) + .compile(); + app = module.createNestApplication(); + configureApp(app); + await app.init(); + await app.listen(0, '127.0.0.1'); + prisma = app.get(PrismaService); + await prisma.user.create({ + data: { + id: 'income-owner', + email: 'income@example.invalid', + passwordHash: 'synthetic', + }, + }); + }); + afterAll(async () => app?.close()); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + jest.clearAllMocks(); + upstream.validateToken.mockResolvedValue( + ['a', 'b'].map((id) => ({ + id, + name: id, + type: 'ACCOUNT_TYPE_TINKOFF', + openedDate: { seconds: '1577836800' }, + })), + ); + upstream.getOperationsByCursor.mockImplementation( + () => new Promise(() => undefined), + ); + await request(app.getHttpServer()) + .put('/api/v1/tbank-connection') + .set('Authorization', authorization) + .send({ token: 'synthetic' }) + .expect(200); + revision = ( + await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'income-owner' }, + }) + ).revision; + for (const accountId of ['a', 'b']) { + await coverage(accountId); + 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: { + userId: 'income-owner', + revision, + accountId, + brokerOperationId: id, + category: 'income', + occurredAt: new Date('2025-01-15Z'), + payload: { + id, + type, + date: { seconds: String(Date.parse('2025-01-15Z') / 1000) }, + payment: { units, nano, currency }, + }, + }, + }); + } + }); + it('returns exact selected sums without a false zero comparison; reopen and observe need no RPC', async () => { + for (let index = 0; index < 2; index++) { + const result = await read(); + assert(result.data); + expect(result.meta.availability).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.entries + .filter((row) => row.id === 'same') + .map((row) => row.account?.id), + ).toEqual(['a', 'b']); + expect(result.data.previous).toBeNull(); + expect(result.data.yearComparison).toBeNull(); + expect(result.meta.dependencies?.[1].availability).toBe('missing'); + expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); + await prisma.$disconnect(); + } + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); + it('requires every account and separates missing, partial and confirmed empty periods', async () => { + await prisma.tbankOperationCoverage.deleteMany({ + where: { accountId: 'b' }, + }); + expect((await read()).data).toBeNull(); + expect((await read()).meta.availability).toBe('partial'); + expect((await read('a')).data).not.toBeNull(); + expect((await read('a', 'from=2025-03-01&to=2025-03-31')).data).toBeNull(); + await coverage( + 'a', + new Date('2025-02-28T21:00:00Z'), + new Date('2025-03-31T21:00:00Z'), + ); + expect( + (await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency, + ).toEqual([]); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); + it('completes previous/yearAgo independently and counts corrected identities once', async () => { + for (const account of ['a', 'b']) { + await coverage(account, new Date('2024-11-30T21:00:00Z'), from); + await coverage( + account, + new Date('2023-12-31T21:00:00Z'), + new Date('2024-01-31T21:00:00Z'), + ); + } + await prisma.tbankOperation.updateMany({ + where: { accountId: 'a', brokerOperationId: 'same' }, + data: { + payload: { + id: 'same', + type: 'OPERATION_TYPE_COUPON', + date: { seconds: String(Date.parse('2025-01-15Z') / 1000) }, + payment: { units: '20', nano: 1, currency: 'rub' }, + }, + }, + }); + const result = await read(); + assert(result.data?.previous && result.data.yearComparison); + expect(result.data.entries).toHaveLength(6); + expect( + result.data.previous.totalsByCurrency[0].difference.total.value, + ).toBe('25.000000002'); + expect( + result.data.yearComparison.totalsByCurrency[0].compared.total.value, + ).toBe('0'); + }); + it('preserves the complete financial result and timestamp through failed atomic reconciliation', async () => { + const before = await read(); + const repo = app.get(BrokerageOperationsRepository); + const attempt = await repo.request( + 'income-owner', + revision, + ['a', 'b'].map((accountId) => ({ accountId, from, to })), + 'test', + true, + new Set(), + 1, + ); + assert(attempt); + const fence = { + id: attempt.work.id, + attemptId: attempt.work.attemptId, + userId: 'income-owner', + revision, + }; + const pending = await repo.pending(fence); + assert(pending); + await repo.stagePage(fence, pending.intervals[0], undefined, { + hasNext: true, + nextCursor: 'unfinished', + items: [ + { + id: 'new', + type: 'OPERATION_TYPE_COUPON', + date: { seconds: String(pending.intervals[0].from.valueOf() / 1000) }, + payment: { currency: 'rub', units: '999' }, + }, + ], + }); + expect((await read()).data).toEqual(before.data); + await repo.fail(fence, false); + const after = await read(); + expect(after.data).toEqual(before.data); + expect(after.meta.sync.state).toBe('failed'); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); + it('retains current-month observed completeness and does not renew source timestamps', async () => { + const now = new Date(); + const today = moscowDate(now); + const start = new Date(`${today.slice(0, 7)}-01T00:00:00+03:00`); + const asOf = new Date(now.valueOf() - 10_000); + for (const accountId of ['a', 'b']) { + await coverage(accountId, start, asOf); + } + const response = await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/all/income-summary?sync=observe') + .set('Authorization', authorization) + .expect(200); + const result = response.body as IncomeSummaryResourceDto; + expect(result.data?.period).toEqual({ + from: `${today.slice(0, 7)}-01`, + to: today, + }); + expect(result.meta.availability).toBe('complete'); + expect(result.meta.coverage?.asOf).toBe(asOf.toISOString()); + expect(result.data?.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); + it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => { + await get('absent', undefined, false).expect(404); + await get('a', 'from=2025-02-31&to=2025-03-01', false).expect(400); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + const response = await get('all', undefined, false).expect(200); + expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull(); + }); + it('distinguishes unavailable calendar comparison without scheduling it', async () => { + const result = await read('a', 'from=2024-02-29&to=2024-02-29'); + expect(result.meta.dependencies?.at(-1)?.unavailableReason).toBe( + 'CALENDAR_OR_ACCOUNT_HISTORY', + ); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); +}); diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts new file mode 100644 index 0000000..5f46ea6 --- /dev/null +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict'; +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; + +const uid = '11111111-1111-4111-8111-111111111111'; +const period = 'from=2025-01-01&to=2025-01-31'; +describe('persistent ledger HTTP and cursor (SQLite)', () => { + let app: INestApplication; + let prisma: PrismaService; + const upstream = { + validateToken: jest.fn(), + getOpenAccounts: jest.fn(), + getOperationsByCursor: jest.fn(), + }; + const auth = (owner = 'owner') => + `Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`; + const get = (query = '', account = 'all', owner = 'owner') => + request(app.getHttpServer()) + .get( + `/api/v1/brokerage-accounts/${account}/operations?${period}&${query}`, + ) + .set('Authorization', auth(owner)) + .timeout(2000); + const put = (owner = 'owner') => + request(app.getHttpServer()) + .put('/api/v1/tbank-connection') + .set('Authorization', auth(owner)) + .send({ token: 'synthetic' }); + beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TbankClient) + .useValue(upstream) + .compile(); + app = module.createNestApplication(); + configureApp(app); + await app.init(); + await app.listen(0, '127.0.0.1'); + prisma = app.get(PrismaService); + for (const id of ['owner', 'other']) + await prisma.user.create({ + data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, + }); + }); + afterAll(async () => app?.close()); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + jest.clearAllMocks(); + upstream.validateToken.mockResolvedValue( + ['a', 'b'].map((id) => ({ + id, + name: id, + type: 'ACCOUNT_TYPE_TINKOFF', + openedDate: { seconds: '1577836800' }, + })), + ); + upstream.getOperationsByCursor + .mockReset() + .mockResolvedValue({ hasNext: false, items: [] }); + await put().expect(200); + const { revision } = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + for (const accountId of ['a', 'b']) { + await prisma.tbankOperationCoverage.create({ + data: { + userId: 'owner', + revision, + accountId, + from: new Date('2020-01-01Z'), + to: new Date(), + asOf: new Date(), + fetchedAt: new Date(), + }, + }); + for (const id of ['1', '2', '3']) + await prisma.tbankOperation.create({ + data: { + userId: 'owner', + revision, + accountId, + brokerOperationId: id, + occurredAt: new Date('2025-01-02Z'), + category: id === '3' ? 'trade' : 'income', + instrumentUid: uid, + payload: { + id, + instrumentUid: uid, + date: { seconds: '1735776000' }, + type: id === '3' ? 'OPERATION_TYPE_BUY' : 'OPERATION_TYPE_COUPON', + payment: { currency: 'rub', units: '1', nano: 1 }, + }, + }, + }); + } + }); + it('reads ordered pages with identical IDs/timestamps across accounts and no upstream RPC, including after reopen', async () => { + let cursor: string | null = null; + const ids: string[] = []; + do { + const response = await get( + `limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + ).expect(200); + const result = response.body as BrokerageOperationsResourceDto; + expect(result.meta.availability).toBe('complete'); + assert(result.data); + ids.push( + ...result.data.items.map((item) => `${item.account?.id}/${item.id}`), + ); + expect(result.data.items[0].payment).toEqual({ + currency: 'RUB', + value: '1.000000001', + }); + cursor = result.data.nextCursor; + if (cursor) await prisma.$disconnect(); + } while (cursor); + expect(ids).toEqual(['a/1', 'a/2', 'a/3', 'b/1', 'b/2', 'b/3']); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + }); + it.each(['default', 'today', 'today timestamp'])( + 'keeps observed current coverage complete through %s period pagination without RPC', + async (periodKind) => { + const observedAt = new Date(Date.now() - 1000); + await prisma.tbankOperationCoverage.updateMany({ + data: { + to: observedAt, + asOf: observedAt, + fetchedAt: observedAt, + }, + }); + await prisma.tbankOperation.updateMany({ + data: { occurredAt: new Date(observedAt.valueOf() - 1000) }, + }); + const query = + periodKind === 'default' + ? '' + : `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; + const page = (cursor?: string, observe = false) => + request(app.getHttpServer()) + .get( + `/api/v1/brokerage-accounts/all/operations?${observe ? 'sync=observe&' : ''}limit=1${query}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + ) + .set('Authorization', auth()); + const first = (await page().expect(200)) + .body as BrokerageOperationsResourceDto; + expect(first.meta.availability).toBe('complete'); + assert(first.data?.nextCursor); + const second = (await page(first.data.nextCursor).expect(200)) + .body as BrokerageOperationsResourceDto; + expect(second.meta.availability).toBe('complete'); + expect(second.meta.coverage?.missingRanges).toEqual([]); + const observed = (await page(first.data.nextCursor, true).expect(200)) + .body as BrokerageOperationsResourceDto; + expect(observed.meta.availability).toBe('complete'); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }, + ); + it('keeps status versions stable between observational reads of a moving period', async () => { + const observe = () => + request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100') + .set('Authorization', auth()); + const first = (await observe().expect(200)) + .body as BrokerageOperationsResourceDto; + await delay(20); + const second = (await observe().expect(200)) + .body as BrokerageOperationsResourceDto; + expect(second.meta.coverage?.requested).not.toEqual( + first.meta.coverage?.requested, + ); + expect(second.meta.stateVersion).toBe(first.meta.stateVersion); + await prisma.tbankOperationCoverage.updateMany({ + data: { + to: new Date(Date.now() - 60_000), + asOf: new Date(Date.now() - 60_000), + }, + }); + const changed = (await observe().expect(200)) + .body as BrokerageOperationsResourceDto; + expect(changed.meta.stateVersion).not.toBe(second.meta.stateVersion); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); + it('reads shared metadata from SQLite and changes stateVersion without invalidating a row cursor', async () => { + const before = (await get('limit=1&sync=observe').expect(200)) + .body as BrokerageOperationsResourceDto; + assert(before.data?.nextCursor); + const { revision } = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + const metadata = new InstrumentMetadataRepository(prisma); + const reservation = await metadata.reserve( + { userId: 'owner', revision }, + { instrumentUid: uid }, + 'metadata-test', + new Set(), + ); + assert(reservation); + await metadata.publish( + { + userId: 'owner', + revision, + id: reservation.work.id, + attemptId: reservation.work.attemptId, + }, + [ + { + type: 'uid', + id: uid, + data: { uid, name: 'Synthetic', brand: { logoName: 'saved.png' } }, + }, + ], + new Date(), + ); + await prisma.$disconnect(); + const after = (await get('limit=1&sync=observe').expect(200)) + .body as BrokerageOperationsResourceDto; + expect(after.data?.items[0].logoName).toBe('saved.png'); + expect(after.meta.stateVersion).not.toBe(before.meta.stateVersion); + expect(after.meta.dataRevision).toBe(before.meta.dataRevision); + await get( + `limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`, + ).expect(200); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); + it('binds a signed cursor to equivalent filters, user, membership, row revision and connection; status alone does not break it', async () => { + const first = ( + await get('limit=1&categories=trade,income&sync=observe').expect(200) + ).body as BrokerageOperationsResourceDto; + assert(first.data?.nextCursor); + const cursor = encodeURIComponent(first.data.nextCursor); + const next = `limit=1&categories=income,trade,income&sync=observe&cursor=${cursor}`; + await prisma.tbankOperationCoverage.updateMany({ + data: { fetchedAt: new Date(0) }, + }); + await get(next).expect(200); + await get(next.replace('limit=1', 'limit=2')).expect(400); + await get(`${next}x`).expect(400); + await put('other').expect(200); + await get(next, 'all', 'other').expect(400); + await prisma.tbankAccount.updateMany({ + where: { userId: 'owner', accountId: 'b' }, + data: { operationsRevision: { increment: 1 } }, + }); + await get(next).expect(400); + const single = (await get('limit=1&sync=observe', 'a').expect(200)) + .body as BrokerageOperationsResourceDto; + assert(single.data?.nextCursor); + const singleNext = `limit=1&sync=observe&cursor=${encodeURIComponent(single.data.nextCursor)}`; + await prisma.tbankConnection.update({ + where: { userId: 'owner' }, + data: { membershipRevision: { increment: 1 } }, + }); + await get(singleNext, 'a').expect(200); + await put().expect(200); + await get(singleNext, 'a').expect(400); + }); + it('does not confuse pagination/categories/instrument subset with interval completeness', async () => { + await prisma.tbankOperationCoverage.deleteMany(); + const { revision } = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + await prisma.tbankOperationCoverage.create({ + data: { + userId: 'owner', + revision, + accountId: 'a', + instrumentPredicate: uid, + from: new Date('2025-01-01Z'), + to: new Date('2025-01-04Z'), + asOf: new Date('2025-01-04Z'), + fetchedAt: new Date(), + }, + }); + const partial = ( + await get( + `instrumentUid=${uid}&categories=income&limit=100&sync=observe`, + 'a', + ).expect(200) + ).body as BrokerageOperationsResourceDto; + expect(partial.meta.availability).toBe('partial'); + expect(partial.data?.hasNext).toBe(false); + expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0); + const whole = (await get('sync=observe', 'a').expect(200)) + .body as BrokerageOperationsResourceDto; + expect(whole.meta.availability).toBe('missing'); + expect(whole.data).toBeNull(); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); + it('keeps an unknown history boundary explicit even when the shown interval is complete', async () => { + await prisma.tbankAccount.updateMany({ data: { openedOn: null } }); + const result = (await get('sync=observe', 'a').expect(200)) + .body as BrokerageOperationsResourceDto; + expect(result.meta.availability).toBe('complete'); + expect(result.meta.coverage?.historyBoundary).toBe('unknown'); + expect(result.meta.coverage?.historyState).toBe('partial'); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); + + it('returns cold missing while upstream is blocked, then confirms an empty interval; observe never retries failure', async () => { + await prisma.tbankOperation.deleteMany(); + await prisma.tbankOperationCoverage.deleteMany(); + let unblock: (value: { hasNext: boolean; items: never[] }) => void = () => { + throw new Error('not initialized'); + }; + upstream.getOperationsByCursor + .mockReturnValueOnce( + new Promise((resolve) => { + unblock = resolve; + }), + ) + .mockRejectedValue(new Error('synthetic outage')); + const cold = (await get('', 'a').expect(200)) + .body as BrokerageOperationsResourceDto; + expect(cold.data).toBeNull(); + expect(cold.meta.availability).toBe('missing'); + await get('sync=observe', 'a').expect(200); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + unblock({ hasNext: false, items: [] }); + for (let index = 0; index < 300; index += 1) { + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + break; + await delay(5); + } + const count = upstream.getOperationsByCursor.mock.calls.length; + const result = (await get('sync=observe', 'a').expect(200)) + .body as BrokerageOperationsResourceDto; + expect(result.data?.items).toEqual([]); + expect(result.meta.availability).toBe('complete'); + await get('sync=observe', 'a').expect(200); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(count); + }); +}); diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts new file mode 100644 index 0000000..be20a87 --- /dev/null +++ b/test/tbank-local-reads.e2e-spec.ts @@ -0,0 +1,441 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { + TbankClient, + type TbankAccount, + type TbankPortfolio, +} from '../src/integrations/tbank/tbank.client'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; +import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto'; + +function deferred() { + let resolve: (value: T) => void = () => { + throw new Error('Uninitialized deferred'); + }; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('local accounts and portfolio HTTP (SQLite)', () => { + let app: INestApplication; + let prisma: PrismaService; + const accounts = [ + { id: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF' }, + ]; + const upstream = { + validateToken: jest.fn(), + getOpenAccounts: jest.fn, [string]>(), + getPortfolio: jest.fn, [string, string]>(), + getPositions: jest.fn(), + getInstrumentByUid: jest.fn(), + getInstrumentByFigi: jest.fn(), + }; + const auth = (owner = 'owner') => + `Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`; + const get = (path: string, owner = 'owner') => + request(app.getHttpServer()) + .get(`/api/v1/${path}`) + .set('Authorization', auth(owner)) + .timeout(1500); + const put = () => + request(app.getHttpServer()) + .put('/api/v1/tbank-connection') + .set('Authorization', auth()) + .send({ token: 'synthetic' }); + async function settled(scope = 'portfolio:') { + for (let index = 0; index < 500; index += 1) { + const work = await prisma.tbankSyncWork.findFirst({ + where: { scopeKey: { startsWith: scope } }, + }); + if (work && ['complete', 'failed'].includes(work.state)) return work; + await delay(2); + } + throw new Error(`Work did not settle: ${scope}`); + } + beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TbankClient) + .useValue(upstream) + .compile(); + app = module.createNestApplication(); + configureApp(app); + await app.init(); + await app.listen(0, '127.0.0.1'); + prisma = app.get(PrismaService); + for (const id of ['owner', 'other']) + await prisma.user.create({ + data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, + }); + }); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + jest.clearAllMocks(); + upstream.validateToken.mockResolvedValue(accounts); + upstream.getOpenAccounts.mockReset().mockResolvedValue(accounts); + upstream.getPortfolio.mockReset().mockResolvedValue({ + positions: [ + { + instrumentType: 'share', + figi: 'stock', + quantity: { units: '2' }, + currentPrice: { units: '10', nano: 1, currency: 'rub' }, + }, + { instrumentType: 'share', figi: 'zero', quantity: { units: '0' } }, + ], + }); + upstream.getPositions.mockResolvedValue({ + money: [{ units: '1', nano: 100000000, currency: 'rub' }], + }); + }); + afterAll(async () => app?.close()); + + it('returns missing without awaiting discovery, observe starts nothing, and unknown accounts become 404 after discovery', async () => { + await prisma.tbankConnection.create({ + data: { + userId: 'owner', + connectedAt: new Date(), + encryptedToken: app.get(TbankTokenService).encrypt('synthetic'), + }, + }); + const blocked = deferred(); + upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise); + const cold = await get('brokerage-accounts/unknown/portfolio').expect(200); + expect((cold.body as PortfolioResourceDto).data).toBeNull(); + expect((cold.body as PortfolioResourceDto).meta.availability).toBe( + 'missing', + ); + await get('brokerage-accounts?sync=observe').expect(200); + expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); + expect(upstream.getPortfolio).not.toHaveBeenCalled(); + blocked.resolve(accounts); + await settled('membership'); + await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(404); + await get('brokerage-accounts?sync=observe', 'other').expect(409); + await get('brokerage-accounts?sync=unexpected').expect(400); + }); + + it('uses saved portfolio for accounts and portfolio without GetAccounts, decrypt or metadata RPC on a warm read', async () => { + await prisma.tbankInstrumentLookup.upsert({ + where: { + provider_identifierType_identifier: { + provider: 'tbank', + identifierType: 'figi', + identifier: 'stock', + }, + }, + create: { + provider: 'tbank', + identifierType: 'figi', + identifier: 'stock', + matchedUids: [], + fetchedAt: new Date(), + }, + update: { matchedUids: [], fetchedAt: new Date() }, + }); + + await put().expect(200); + const blocked = deferred(); + upstream.getPortfolio.mockReturnValueOnce(blocked.promise); + const cold = await get('brokerage-accounts').expect(200); + expect( + (cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary, + ).toBeNull(); + blocked.resolve({ + positions: [ + { + instrumentType: 'share', + figi: 'stock', + quantity: { units: '2' }, + currentPrice: { units: '10', nano: 1, currency: 'rub' }, + }, + ], + }); + await settled(); + const decrypt = jest.spyOn(app.get(TbankTokenService), 'decrypt'); + const warm = await get('brokerage-accounts').expect(200); + expect( + (warm.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary, + ).toEqual({ + stockPositionCount: 1, + bondPositionCount: 0, + totalsByCurrency: [{ currency: 'RUB', value: '21.100000002' }], + }); + const portfolio = await get('brokerage-accounts/account/portfolio').expect( + 200, + ); + expect( + (portfolio.body as PortfolioResourceDto).data?.totalsByCurrency, + ).toEqual([{ currency: 'RUB', value: '21.100000002' }]); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); + expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled(); + expect(decrypt).not.toHaveBeenCalled(); + decrypt.mockRestore(); + const connection = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + expect(JSON.stringify(portfolio.body)).not.toContain(connection.revision); + expect(JSON.stringify(portfolio.body)).not.toContain( + connection.encryptedToken, + ); + }); + + it('overlays new shared labels without changing the saved financial snapshot', async () => { + await put().expect(200); + await get('brokerage-accounts/account/portfolio').expect(200); + await settled(); + const before = ( + await get('brokerage-accounts/account/portfolio?sync=observe').expect(200) + ).body as PortfolioResourceDto; + const fetchedAt = new Date(); + await prisma.tbankInstrument.create({ + data: { + provider: 'tbank', + uid: 'enriched-stock', + figi: 'stock', + instrumentType: 'share', + metadata: { + uid: 'enriched-stock', + figi: 'stock', + name: 'Enriched name', + ticker: 'ENR', + brand: { logoName: 'enriched.png' }, + }, + fetchedAt, + }, + }); + await prisma.tbankInstrumentLookup.upsert({ + where: { + provider_identifierType_identifier: { + provider: 'tbank', + identifierType: 'figi', + identifier: 'stock', + }, + }, + create: { + provider: 'tbank', + identifierType: 'figi', + identifier: 'stock', + matchedUids: ['enriched-stock'], + fetchedAt, + }, + update: { matchedUids: ['enriched-stock'], fetchedAt }, + }); + const after = ( + await get('brokerage-accounts/account/portfolio?sync=observe').expect(200) + ).body as PortfolioResourceDto; + expect(after.data?.positions[0]).toMatchObject({ + displayName: 'Enriched name', + ticker: 'ENR', + logoName: 'enriched.png', + quantity: '2', + }); + expect(after.data?.totalsByCurrency).toEqual(before.data?.totalsByCurrency); + expect(after.data?.lastSuccessfulAt).toBe(before.data?.lastSuccessfulAt); + expect(after.meta.dataRevision).not.toBe(before.meta.dataRevision); + expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); + }); + + it('keeps old totals on failed refresh indefinitely and does not retry through observe', async () => { + await put().expect(200); + await get('brokerage-accounts/account/portfolio').expect(200); + await settled(); + await prisma.tbankPortfolio.updateMany({ + data: { fetchedAt: new Date(0) }, + }); + upstream.getPortfolio.mockRejectedValueOnce(new Error('synthetic outage')); + const stale = await get('brokerage-accounts/account/portfolio').expect(200); + const previous = (stale.body as PortfolioResourceDto).data; + expect(previous?.isStale).toBe(true); + expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString()); + await settled(); + const failed = await get( + 'brokerage-accounts/account/portfolio?sync=observe', + ).expect(200); + expect((failed.body as PortfolioResourceDto).data).toEqual(previous); + expect((failed.body as PortfolioResourceDto).meta.sync).toMatchObject({ + state: 'failed', + errorCode: 'TBANK_UNAVAILABLE', + }); + expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); + }); + + it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => { + await put().expect(200); + const connection = await prisma.tbankConnection.update({ + where: { userId: 'owner' }, + data: { membershipAt: new Date(0) }, + }); + await prisma.tbankSyncWork.create({ + data: { + id: 'abandoned-membership', + userId: 'owner', + revision: connection.revision, + scopeKey: 'membership', + attemptId: 'abandoned', + processRunId: 'terminated-process', + state: 'running', + priority: 0, + intent: {}, + progress: {}, + }, + }); + const blocked = deferred(); + upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise); + await get('brokerage-accounts?sync=observe').expect(200); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + expect( + ( + await prisma.tbankSyncWork.findUniqueOrThrow({ + where: { id: 'abandoned-membership' }, + }) + ).attemptId, + ).toBe('abandoned'); + const reads = await Promise.all( + Array.from({ length: 20 }, () => get('brokerage-accounts').expect(200)), + ); + expect( + reads.every( + (read) => + (read.body as BrokerageAccountsResourceDto).data?.[0].id === + 'account', + ), + ).toBe(true); + expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); + expect( + ( + await prisma.tbankSyncWork.findUniqueOrThrow({ + where: { id: 'abandoned-membership' }, + }) + ).attemptId, + ).not.toBe('abandoned'); + blocked.resolve([]); + await settled('membership'); + await get('brokerage-accounts/account/portfolio?sync=observe').expect(404); + expect(await prisma.tbankPortfolio.count()).toBe(0); + expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({ + data: [], + }); + }); + + it('keeps account summaries independent and never turns a new missing member into zero personal holdings', async () => { + const uid = '11111111-1111-4111-8111-111111111111'; + upstream.validateToken.mockResolvedValueOnce([ + ...accounts, + { id: 'second', name: 'Second', type: 'ACCOUNT_TYPE_TINKOFF' }, + ]); + await put().expect(200); + upstream.getPortfolio.mockImplementation((_token, id) => + id === 'second' + ? Promise.reject(new Error('synthetic unavailable')) + : Promise.resolve({ + positions: [ + { + instrumentUid: uid, + instrumentType: 'share', + quantity: { units: '2' }, + currentPrice: { units: '10', currency: 'rub' }, + }, + ], + }), + ); + await get('brokerage-accounts').expect(200); + for (let index = 0; index < 200; index += 1) { + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + break; + await delay(5); + } + const summaries = (await get('brokerage-accounts?sync=observe').expect(200)) + .body as BrokerageAccountsResourceDto; + expect(summaries.data?.[0].portfolioSummary?.totalsByCurrency).toEqual([ + { currency: 'RUB', value: '21.1' }, + ]); + expect(summaries.data?.[1].portfolioSummary).toBeNull(); + expect(summaries.meta.availability).toBe('partial'); + expect( + (await get('brokerage-accounts/all/portfolio?sync=observe')).body, + ).toMatchObject({ data: null, meta: { availability: 'partial' } }); + expect( + (await get(`stocks/${uid}/position?sync=observe`).expect(200)).body, + ).toMatchObject({ data: null, meta: { availability: 'partial' } }); + expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); + await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } }); + await prisma.tbankConnection.updateMany({ + data: { membershipRevision: { increment: 1 } }, + }); + const position = await get(`stocks/${uid}/position?sync=observe`).expect( + 200, + ); + expect(position.body).toMatchObject({ + data: { quantity: '2', accounts: [{ account: { id: 'account' } }] }, + meta: { availability: 'complete' }, + }); + await prisma.$disconnect(); + expect( + (await get(`stocks/${uid}/position?sync=observe`).expect(200)).body, + ).toEqual(position.body); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); + await prisma.tbankConnection.updateMany({ data: { invalidToken: true } }); + await get(`stocks/${uid}/position?sync=observe`).expect(400); + }); + + it('uses membership TTL for an empty aggregate without starting work through observation', async () => { + upstream.validateToken.mockResolvedValueOnce([]); + await put().expect(200); + await prisma.tbankConnection.updateMany({ + data: { membershipAt: new Date(Date.now() - 120_000) }, + }); + const fresh = await get( + 'brokerage-accounts/all/portfolio?sync=observe', + ).expect(200); + expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh'); + expect((fresh.body as PortfolioResourceDto).data).toMatchObject({ + isStale: false, + positions: [], + totalsByCurrency: [], + }); + await prisma.tbankConnection.updateMany({ + data: { membershipAt: new Date(Date.now() - 301_000) }, + }); + const stale = await get( + 'brokerage-accounts/all/portfolio?sync=observe', + ).expect(200); + expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('stale'); + expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(true); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + expect(upstream.getPortfolio).not.toHaveBeenCalled(); + }); + + it('publishes complete empty membership and fences a late portfolio after connection replacement', async () => { + upstream.validateToken.mockResolvedValueOnce([]); + await put().expect(200); + const empty = await get('brokerage-accounts?sync=observe').expect(200); + expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]); + expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe( + 'complete', + ); + await put().expect(200); + const blocked = deferred(); + upstream.getPortfolio.mockReturnValueOnce(blocked.promise); + await get('brokerage-accounts/account/portfolio').expect(200); + await put().expect(200); + blocked.resolve({ positions: [] }); + await delay(20); + const replaced = await get( + 'brokerage-accounts/account/portfolio?sync=observe', + ).expect(200); + expect((replaced.body as PortfolioResourceDto).data).toBeNull(); + expect(await prisma.tbankPortfolio.count()).toBe(0); + }); +}); diff --git a/test/tbank-metadata-persistence.e2e-spec.ts b/test/tbank-metadata-persistence.e2e-spec.ts new file mode 100644 index 0000000..9bb117a --- /dev/null +++ b/test/tbank-metadata-persistence.e2e-spec.ts @@ -0,0 +1,392 @@ +import assert from 'node:assert/strict'; +import { ConfigService } from '@nestjs/config'; +import { setTimeout as delay } from 'node:timers/promises'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; +import { + InstrumentMetadataRepository, + metadataKey, +} from '../src/integrations/tbank/instrument-metadata.repository'; +import type { TbankInstrument } from '../src/integrations/tbank/tbank.client'; + +// Shared publication is proven against real FK/transaction/connection boundaries. +describe('durable shared instrument metadata (SQLite)', () => { + const prisma = new PrismaService( + new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), + ); + const tokens = new TbankTokenService( + new ConfigService({ + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + }) as never, + ); + const upstream = { + getInstrumentByUid: jest.fn(), + getInstrumentByFigi: jest.fn(), + }; + const access = new TbankAccessService(prisma, tokens, upstream as never); + const repository = new InstrumentMetadataRepository(prisma); + const logger = { event: jest.fn() }; + const createService = () => + new InstrumentMetadataService( + upstream as never, + logger as never, + repository, + access, + ); + let service: InstrumentMetadataService; + const identity = { instrumentUid: 'uid', figi: 'FIGI' }; + const known = { + uid: 'uid', + figi: 'FIGI', + ticker: 'TEST', + name: 'Synthetic instrument', + brand: { logoName: 'test.png' }, + }; + const seedConnection = (userId: string) => + prisma.tbankConnection.create({ + data: { + userId, + revision: `${userId}-revision`, + encryptedToken: tokens.encrypt(`synthetic-${userId}`), + connectedAt: new Date(), + }, + }); + async function settled() { + for (let index = 0; index < 300; index++) { + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + return; + await delay(5); + } + throw new Error('Metadata work did not settle'); + } + beforeAll(async () => { + await prisma.$connect(); + for (const id of ['owner', 'other']) + await prisma.user.create({ + data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, + }); + }); + afterAll(async () => prisma.$disconnect()); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + await prisma.tbankInstrumentLookup.deleteMany(); + await prisma.tbankInstrument.deleteMany(); + await seedConnection('owner'); + await seedConnection('other'); + jest.resetAllMocks(); + upstream.getInstrumentByUid.mockResolvedValue(known); + upstream.getInstrumentByFigi.mockResolvedValue(known); + service = createService(); + }); + it('reuses durable successful metadata across users and reopen without decrypt or RPC', async () => { + const cold = await service.read('owner', identity); + expect(cold?.data).toBeNull(); + expect(cold?.work?.state).toBe('running'); + await settled(); + await prisma.$disconnect(); + service = createService(); + const decrypt = jest.spyOn(tokens, 'decrypt'); + const other = await service.read('other', identity); + expect(other?.data).toEqual(known); + expect(other?.fresh).toBe(true); + expect(other?.work).toBeNull(); + expect((await service.read('other', { figi: 'FIGI' }))?.data).toEqual( + known, + ); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1); + expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled(); + expect(decrypt).not.toHaveBeenCalled(); + decrypt.mockRestore(); + const rows = await prisma.tbankInstrument.findMany(); + expect(JSON.stringify(rows)).not.toMatch( + /synthetic-owner|owner-revision|quantity|estimatedAmount/, + ); + }); + it('persists UID miss separately from successful FIGI fallback and confirmed empty lookups', async () => { + upstream.getInstrumentByUid.mockResolvedValue(null); + await service.read('owner', identity); + await settled(); + const result = await service.read('other', identity); + expect(result?.data).toEqual(known); + expect(result?.complete).toBe(true); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1); + expect(upstream.getInstrumentByFigi).toHaveBeenCalledTimes(1); + upstream.getInstrumentByFigi.mockResolvedValue(null); + const missing = { figi: 'UNKNOWN' }; + await service.read('owner', missing); + await settled(); + const empty = await service.read('other', missing); + expect(empty?.data).toBeNull(); + expect(empty?.complete).toBe(true); + expect(empty?.fresh).toBe(true); + expect(upstream.getInstrumentByFigi).toHaveBeenCalledTimes(2); + }); + it('coalesces twenty requests across users while giving each user a private handle', async () => { + const blocked = deferred(); + upstream.getInstrumentByUid.mockReturnValue(blocked.promise); + const responses = await Promise.all( + Array.from({ length: 20 }, (_, i) => + service.read(i % 2 ? 'owner' : 'other', identity), + ), + ); + await delay(10); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1); + const ids = new Set(responses.map((response) => response?.work?.id)); + expect(ids.size).toBe(2); + expect(responses[0]?.work?.id).not.toBe(responses[1]?.work?.id); + blocked.resolve(known); + await settled(); + expect(await prisma.tbankInstrument.count()).toBe(1); + expect( + await prisma.tbankSyncWork.count({ where: { state: 'complete' } }), + ).toBe(2); + }); + it.each(['delete', 'replace'])( + 'discards a producer after %s, including shared freshness, without borrowing another user token', + async (action) => { + const blocked = deferred(); + upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise); + await service.read('owner', identity); + await delay(10); + await service.read('other', identity); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + if (action === 'replace') + await prisma.tbankConnection.create({ + data: { + userId: 'owner', + revision: 'replacement', + encryptedToken: tokens.encrypt('replacement-token'), + connectedAt: new Date(), + }, + }); + blocked.resolve(known); + await settled(); + expect(await prisma.tbankInstrument.count()).toBe(0); + expect(await prisma.tbankInstrumentLookup.count()).toBe(0); + expect( + (await service.read('other', identity, true))?.work?.errorCode, + ).toBe('TBANK_UNAVAILABLE'); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1); + await service.read('other', identity); + await settled(); + expect((await service.read('other', identity, true))?.data).toEqual( + known, + ); + expect(upstream.getInstrumentByUid).toHaveBeenLastCalledWith( + 'synthetic-other', + 'uid', + ); + }, + ); + it('does not turn initiating credentials failure into a shared negative lookup or a foreign credentials error', async () => { + const blocked = deferred(); + upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise); + await service.read('owner', identity); + await delay(10); + await service.read('other', identity); + blocked.reject(new Error('INVALID_TBANK_TOKEN')); + await settled(); + await expect(service.read('owner', identity, true)).rejects.toMatchObject({ + response: { code: 'INVALID_TBANK_TOKEN' }, + }); + expect((await service.read('other', identity, true))?.work?.errorCode).toBe( + 'TBANK_UNAVAILABLE', + ); + expect(await prisma.tbankInstrumentLookup.count()).toBe(0); + await service.read('other', identity); + await settled(); + expect((await service.read('other', identity, true))?.data).toEqual(known); + }); + it('keeps last good metadata and source time after refresh failure; stale observation does not restart work', async () => { + await service.read('owner', identity); + await settled(); + const old = new Date(Date.now() - 86_400_000); + await prisma.tbankInstrumentLookup.updateMany({ data: { fetchedAt: old } }); + await prisma.tbankInstrument.updateMany({ data: { fetchedAt: old } }); + const calls = upstream.getInstrumentByUid.mock.calls.length; + expect((await service.read('owner', identity, true))?.fresh).toBe(false); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls); + upstream.getInstrumentByUid.mockRejectedValueOnce( + new Error('upstream unavailable'), + ); + upstream.getInstrumentByFigi.mockRejectedValueOnce( + new Error('upstream unavailable'), + ); + await service.read('owner', identity); + await settled(); + const failed = await service.read('owner', identity, true); + expect(failed?.data).toEqual(known); + expect(failed?.fetchedAt).toEqual(old); + expect(failed?.work?.state).toBe('failed'); + await delay(20); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(calls + 1); + }); + it('coalesces forced refresh and retains freshness/timestamp when it fails before the TTL', async () => { + await service.read('owner', identity); + await settled(); + const before = await service.read('owner', identity, true); + const blocked = deferred(); + upstream.getInstrumentByUid.mockReturnValueOnce(blocked.promise); + upstream.getInstrumentByFigi.mockRejectedValueOnce( + new Error('also unavailable'), + ); + const handles = await Promise.all( + Array.from({ length: 20 }, () => + service.request('owner', 'owner-revision', identity, true), + ), + ); + expect(new Set(handles).size).toBe(1); + blocked.reject(new Error('unavailable')); + await settled(); + const after = await service.read('owner', identity, true); + expect(after?.data).toEqual(before?.data); + expect(after?.fetchedAt).toEqual(before?.fetchedAt); + expect(after?.fresh).toBe(true); + expect(after?.work?.state).toBe('failed'); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(2); + }); + it('uses the exact daily TTL boundary and does not count another instrument as covered', async () => { + await service.read('owner', identity); + await settled(); + const timestamp = new Date('2026-09-01Z'); + await prisma.tbankInstrumentLookup.updateMany({ + data: { fetchedAt: timestamp }, + }); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(timestamp.valueOf() + 86_400_000 - 1); + try { + expect((await service.read('owner', identity, true))?.fresh).toBe(true); + clock.mockReturnValue(timestamp.valueOf() + 86_400_000); + expect((await service.read('owner', identity, true))?.fresh).toBe(false); + expect( + (await service.read('owner', { instrumentUid: 'other-uid' }, true)) + ?.complete, + ).toBe(false); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1); + } finally { + clock.mockRestore(); + } + }); + it('recovers abandoned work only on a new request after reopen, with a new attempt', async () => { + const old = await repository.reserve( + { userId: 'owner', revision: 'owner-revision' }, + identity, + 'previous-process', + new Set(), + ); + assert(old); + await prisma.$disconnect(); + service = createService(); + await service.read('owner', identity, true); + await delay(10); + expect(upstream.getInstrumentByUid).not.toHaveBeenCalled(); + await service.read('owner', identity); + await settled(); + const work = await prisma.tbankSyncWork.findUniqueOrThrow({ + where: { id: old.work.id }, + }); + expect(work.attemptId).not.toBe(old.work.attemptId); + expect(work.state).toBe('complete'); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1); + }); + it('rejects publication by a superseded attempt without changing payload or successful timestamp', async () => { + const owner = { userId: 'owner', revision: 'owner-revision' }; + const old = await repository.reserve(owner, identity, 'old', new Set()); + assert(old); + const current = await repository.reserve( + owner, + identity, + 'current', + new Set(), + ); + assert(current); + const fence = (work: typeof old.work) => ({ + ...owner, + id: work.id, + attemptId: work.attemptId, + }); + expect( + await repository.publish( + fence(current.work), + [{ type: 'uid', id: 'uid', data: known }], + new Date(1000), + ), + ).toBe(true); + expect( + await repository.publish( + fence(old.work), + [{ type: 'uid', id: 'uid', data: { ...known, name: 'late' } }], + new Date(2000), + ), + ).toBe(false); + const saved = (await repository.readMany([identity])).get( + metadataKey(identity), + ); + expect(saved?.data).toEqual(known); + expect(saved?.fetchedAt).toEqual(new Date(1000)); + }); + it('uses a verified FIGI fallback after transient UID failure without persisting a false UID miss', async () => { + upstream.getInstrumentByUid.mockRejectedValueOnce( + new Error('temporarily unavailable'), + ); + await service.read('owner', identity); + await settled(); + const result = await service.read('other', identity, true); + expect(result?.data).toEqual(known); + expect(result?.complete).toBe(true); + const lookup = await prisma.tbankInstrumentLookup.findFirstOrThrow({ + where: { identifierType: 'uid', identifier: 'uid' }, + }); + expect(lookup.matchedUids).toEqual(['uid']); + }); + it('reads a full 1000-row ledger metadata batch without SQLite expression-depth overflow', async () => { + await service.read('owner', identity); + await settled(); + const identities = Array.from({ length: 1000 }, (_, index) => + index + ? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } + : identity, + ); + const saved = await service.savedMany(identities); + expect(saved.size).toBe(1000); + expect(saved.get(metadataKey(identity))?.data).toEqual(known); + expect(upstream.getInstrumentByUid).toHaveBeenCalledTimes(1); + }); + it('does not cache a sparse malformed UID result as complete metadata', async () => { + upstream.getInstrumentByUid.mockResolvedValue({ uid: 'uid', name: ' ' }); + await service.read('owner', { instrumentUid: 'uid' }); + await settled(); + const result = await service.read('owner', { instrumentUid: 'uid' }, true); + expect(result?.complete).toBe(false); + expect(result?.work?.state).toBe('failed'); + expect(await prisma.tbankInstrumentLookup.count()).toBe(0); + }); + it('does not fabricate a provider UID from a malformed FIGI response', async () => { + upstream.getInstrumentByFigi.mockResolvedValue({ + name: 'No provider identity', + }); + await service.read('owner', { figi: 'FIGI' }); + await settled(); + expect(await prisma.tbankInstrument.count()).toBe(0); + expect(await prisma.tbankInstrumentLookup.count()).toBe(0); + expect( + (await service.read('owner', { figi: 'FIGI' }, true))?.work?.state, + ).toBe('failed'); + }); +}); +function deferred() { + let resolve: (value: T) => void = () => { + throw new Error('Uninitialized deferred'); + }; + let reject: (error: Error) => void = () => { + throw new Error('Uninitialized deferred'); + }; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} diff --git a/test/tbank-migration.e2e-spec.ts b/test/tbank-migration.e2e-spec.ts new file mode 100644 index 0000000..db674e9 --- /dev/null +++ b/test/tbank-migration.e2e-spec.ts @@ -0,0 +1,253 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { PrismaClient } from '../generated/prisma/client'; + +const migration = '20260924170000_persist_tbank_data'; +const migrations = join(__dirname, '../prisma/migrations'); +const preserved = [ + 'User', + 'Session', + 'BankCard', + 'ExpenseCategory', + 'BankCardOperation', +] as const; + +describe('T-Bank additive migration on real SQLite', () => { + it.each([false, true])( + 'preserves existing data (populated=%s), keys and decimal strings after reopen', + async (populated) => { + const directory = process.env.WL_E2E_DIRECTORY; + if (!directory) throw new Error('Isolated SQLite runner required'); + const file = join(directory, `tbank-migration-${populated}.db`); + let db = new DatabaseSync(file); + db.exec('PRAGMA foreign_keys=ON'); + for (const name of readdirSync(migrations) + .filter((name) => name < migration && /^\d/.test(name)) + .sort()) + db.exec(readFileSync(join(migrations, name, 'migration.sql'), 'utf8')); + if (populated) { + db.exec(` + INSERT INTO User (id,email,passwordHash,createdAt,updatedAt) VALUES + ('owner','owner@example.invalid','synthetic',0,0), + ('other','other@example.invalid','synthetic',0,0); + INSERT INTO Session (id,userId,refreshTokenHash,expiresAt,createdAt,updatedAt) + VALUES ('session','owner','synthetic-hash',9000000000000,0,0); + INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES + ('owner','synthetic-ciphertext-preserve-exactly',0), + ('other','different-synthetic-ciphertext',0); + INSERT INTO BankCard (id,userId,name,openingBalanceKopecks,openingDate,createFingerprint,createdAt,updatedAt) + VALUES ('card','owner','Synthetic',9007199254740993,'2026-01-01','card-intent',0,0); + INSERT INTO ExpenseCategory (id,userId,name,nameKey) VALUES ('category','owner','Synthetic','synthetic'); + INSERT INTO BankCardOperation (id,userId,kind,cardId,categoryId,amountKopecks,date,createFingerprint,createdAt,updatedAt) + VALUES ('operation','owner','expense','card','category',12345,'2026-08-01','operation-intent',0,0); + `); + } + const snapshot = () => + preserved.map((table) => { + const statement = db.prepare(`SELECT * FROM "${table}" ORDER BY id`); + statement.setReadBigInts(true); + return statement.all(); + }); + const before = snapshot(); + const connectionsBefore = db + .prepare( + 'SELECT userId, encryptedToken, connectedAt FROM TbankConnection ORDER BY userId', + ) + .all(); + db.exec( + readFileSync(join(migrations, migration, 'migration.sql'), 'utf8'), + ); + db.close(); + db = new DatabaseSync(file); + db.exec('PRAGMA foreign_keys=ON'); + expect(snapshot()).toEqual(before); + expect( + db + .prepare( + 'SELECT userId, encryptedToken, connectedAt FROM TbankConnection ORDER BY userId', + ) + .all(), + ).toEqual(connectionsBefore); + expect(db.prepare('PRAGMA integrity_check').get()).toEqual({ + integrity_check: 'ok', + }); + expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]); + const revisions = db + .prepare('SELECT revision FROM TbankConnection') + .all() + .map((row) => row.revision); + expect(new Set(revisions).size).toBe(revisions.length); + expect( + revisions.every( + (value) => typeof value === 'string' && value.length > 0, + ), + ).toBe(true); + db.close(); + + const prisma = new PrismaClient({ + adapter: new PrismaBetterSqlite3({ url: `file:${file}` }), + }); + try { + if (!populated) + await prisma.user.create({ + data: { + id: 'owner', + email: 'owner@example.invalid', + passwordHash: 'synthetic', + tbankConnection: { + create: { + encryptedToken: 'synthetic', + connectedAt: new Date(0), + }, + }, + }, + }); + const connection = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + const scope = { + userId: 'owner', + revision: connection.revision, + accountId: 'account', + }; + await prisma.tbankAccount.create({ + data: { + ...scope, + type: 'BROKERAGE', + name: 'Synthetic', + upstreamOrder: 0, + }, + }); + const exact = { currency: 'RUB', value: '-9007199254740993.123456789' }; + const data = { + ...scope, + brokerOperationId: 'broker-id', + occurredAt: new Date('2026-08-01'), + category: 'income', + payload: { payment: exact }, + }; + await prisma.tbankOperation.create({ data }); + await prisma.tbankAccount.create({ + data: { + ...scope, + accountId: 'second', + type: 'BROKERAGE', + name: 'Second', + upstreamOrder: 1, + }, + }); + await prisma.tbankOperation.create({ + data: { ...data, accountId: 'second' }, + }); + await expect( + prisma.tbankOperation.create({ data }), + ).rejects.toMatchObject({ code: 'P2002' }); + await expect( + prisma.tbankOperation.create({ + data: { + ...data, + brokerOperationId: 'foreign', + revision: 'wrong-connection', + }, + }), + ).rejects.toMatchObject({ code: 'P2003' }); + // A revision update cannot silently move private rows to another connection. + await expect( + prisma.tbankConnection.update({ + where: { userId: 'owner' }, + data: { revision: 'replacement' }, + }), + ).rejects.toMatchObject({ code: 'P2003' }); + const saved = await prisma.tbankOperation.findFirstOrThrow({ + where: scope, + }); + expect(saved.payload).toEqual({ payment: exact }); + await prisma.tbankInstrument.create({ + data: { + provider: 'tbank', + uid: 'synthetic-uid', + instrumentType: 'bond', + metadata: { name: 'Synthetic' }, + fetchedAt: new Date(), + }, + }); + await prisma.tbankCandle.create({ + data: { + provider: 'tbank', + instrumentUid: 'synthetic-uid', + interval: 'day', + time: new Date('2026-08-01'), + close: exact.value, + payload: { close: exact.value }, + isComplete: true, + fetchedAt: new Date(), + }, + }); + await prisma.tbankSyncWork.create({ + data: { + id: 'work', + userId: scope.userId, + revision: scope.revision, + scopeKey: 'operations', + intent: {}, + state: 'running', + attemptId: 'new-attempt', + processRunId: 'run', + priority: 0, + progress: {}, + }, + }); + const staged = { + workId: 'work', + attemptId: 'new-attempt', + sourceKey: 'operations', + itemKey: 'broker-id', + payload: { payment: exact }, + }; + await prisma.tbankSyncStage.create({ data: staged }); + await expect( + prisma.tbankSyncStage.create({ + data: { ...staged, attemptId: 'old-attempt' }, + }), + ).rejects.toMatchObject({ code: 'P2003' }); + await prisma.$disconnect(); + await prisma.$connect(); + expect( + (await prisma.tbankOperation.findFirstOrThrow({ where: scope })) + .payload, + ).toEqual({ payment: exact }); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + expect(await prisma.tbankOperation.count()).toBe(0); + expect(await prisma.tbankAccount.count()).toBe(0); + expect(await prisma.tbankSyncWork.count()).toBe(0); + expect(await prisma.tbankSyncStage.count()).toBe(0); + expect((await prisma.tbankCandle.findFirstOrThrow()).close).toBe( + exact.value, + ); + expect(await prisma.bankCard.count()).toBe(populated ? 1 : 0); + expect(await prisma.bankCardOperation.count()).toBe(populated ? 1 : 0); + } finally { + await prisma.$disconnect(); + } + db = new DatabaseSync(file); + const plan = db + .prepare( + 'EXPLAIN QUERY PLAN SELECT * FROM TbankOperation WHERE userId=? AND revision=? AND accountId=? ORDER BY occurredAt, brokerOperationId LIMIT 50', + ) + .all('owner', 'revision', 'account'); + expect( + plan.some((row) => String(row.detail).includes('USING INDEX')), + ).toBe(true); + expect( + plan.some((row) => String(row.detail).includes('TEMP B-TREE')), + ).toBe(false); + expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]); + expect(db.prepare('PRAGMA integrity_check').get()).toEqual({ + integrity_check: 'ok', + }); + db.close(); + }, + ); +}); diff --git a/test/tbank-operations-persistence.e2e-spec.ts b/test/tbank-operations-persistence.e2e-spec.ts new file mode 100644 index 0000000..507b3b5 --- /dev/null +++ b/test/tbank-operations-persistence.e2e-spec.ts @@ -0,0 +1,350 @@ +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; + +// Real transactions are the boundary: staged pages never become financial +// results, and a failed multi-account publication must preserve every old row. +describe('Operations staging and reconciliation (isolated SQLite)', () => { + const prisma = new PrismaService( + new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), + ); + const repository = new BrokerageOperationsRepository(prisma); + const attempt = { + userId: 'owner', + revision: 'revision', + id: 'work', + attemptId: 'attempt', + }; + const interval = { + accountId: 'a', + from: new Date('2026-01-01Z'), + to: new Date('2026-02-01Z'), + }; + const second = { ...interval, accountId: 'b' }; + const operation = (id: string, value = '10') => ({ + id, + date: { seconds: '1768000000' }, + type: 'OPERATION_TYPE_COUPON', + payment: { units: value, nano: 1, currency: 'rub' }, + }); + beforeAll(async () => { + await prisma.$connect(); + await prisma.user.create({ + data: { + id: 'owner', + email: 'owner@example.invalid', + passwordHash: 'synthetic', + }, + }); + }); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + await prisma.tbankConnection.create({ + data: { + userId: 'owner', + revision: 'revision', + encryptedToken: 'synthetic', + connectedAt: new Date(), + membershipAt: new Date(), + accounts: { + create: ['a', 'b'].map((accountId, upstreamOrder) => ({ + accountId, + upstreamOrder, + name: accountId, + type: 'BROKERAGE', + })), + }, + }, + }); + await prisma.tbankSyncWork.create({ + data: { + ...attempt, + scopeKey: 'scope', + intent: {}, + progress: {}, + state: 'running', + processRunId: 'run', + priority: 1, + }, + }); + }); + afterAll(async () => prisma.$disconnect()); + + it('exposes only old or new financial groups to a separate SQLite reader and survives reopen', async () => { + const reader = new PrismaService( + new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), + ); + await reader.$connect(); + try { + for (const scope of [interval, second]) { + await repository.stagePage(attempt, scope, undefined, { + hasNext: false, + items: [operation('row', '10')], + }); + } + await repository.publish(attempt, [interval, second], new Date(1000)); + for (const scope of [interval, second]) { + await repository.stagePage(attempt, scope, undefined, { + hasNext: false, + items: [operation('row', '20')], + }); + } + const snapshots: string[][] = []; + await Promise.all([ + repository.publish(attempt, [interval, second], new Date(2000)), + (async () => { + for (let index = 0; index < 20; index += 1) { + const rows = await reader.tbankOperation.findMany({ + orderBy: { accountId: 'asc' }, + }); + snapshots.push( + rows.map( + (row) => + (row.payload as { payment: { units: string } }).payment.units, + ), + ); + } + })(), + ]); + for (const snapshot of snapshots) + expect([ + ['10', '10'], + ['20', '20'], + ]).toContainEqual(snapshot); + await reader.$disconnect(); + await reader.$connect(); + expect( + (await reader.tbankOperation.findMany()).map( + (row) => + (row.payload as { payment: { units: string } }).payment.units, + ), + ).toEqual(['20', '20']); + } finally { + await reader.$disconnect(); + } + }); + + it('limits instrument reconciliation and refuses conflicting versions across a publication group', async () => { + const instrumentOperation = { + ...operation('instrument'), + instrumentUid: 'uid', + }; + await repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [instrumentOperation, operation('unrelated')], + }); + await repository.publish(attempt, [interval], new Date(1000)); + const subset = { ...interval, instrumentUid: 'uid' }; + await repository.stagePage(attempt, subset, undefined, { + hasNext: false, + items: [], + }); + await repository.publish(attempt, [subset], new Date(2000)); + expect( + (await prisma.tbankOperation.findMany()).map( + (row) => row.brokerOperationId, + ), + ).toEqual(['unrelated']); + expect( + ( + await prisma.tbankOperationCoverage.findFirstOrThrow({ + where: { instrumentPredicate: '' }, + }) + ).fetchedAt, + ).toEqual(new Date(1000)); + const february = { + ...interval, + from: interval.to, + to: new Date('2026-03-01Z'), + }; + await repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [operation('moved')], + }); + await repository.stagePage(attempt, february, undefined, { + hasNext: false, + items: [ + { + ...operation('moved'), + date: { seconds: String(february.from.valueOf() / 1000) }, + }, + ], + }); + await expect( + repository.publish(attempt, [interval, february], new Date(3000)), + ).rejects.toThrow('Conflicting'); + expect( + (await prisma.tbankOperation.findMany()).map( + (row) => row.brokerOperationId, + ), + ).toEqual(['unrelated']); + expect(await prisma.tbankOperationCoverage.count()).toBe(2); + }); + + it('keeps pages private, replaces the full group atomically, and changes revision only for row changes', async () => { + for (const scope of [interval, second]) { + await repository.stagePage(attempt, scope, undefined, { + hasNext: false, + items: [operation('same-id')], + }); + } + await repository.publish(attempt, [interval, second], new Date(1000)); + expect(await prisma.tbankOperation.count()).toBe(2); + expect( + (await prisma.tbankAccount.findMany()).map( + (row) => row.operationsRevision, + ), + ).toEqual([1, 1]); + await repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [operation('same-id', '-20')], + }); + await expect( + repository.publish(attempt, [interval, second], new Date(2000)), + ).rejects.toThrow('Incomplete'); + expect( + (await prisma.tbankOperation.findMany()).every((row) => + JSON.stringify(row.payload).includes('"10"'), + ), + ).toBe(true); + expect( + (await prisma.tbankOperationCoverage.findMany()).every( + (row) => row.fetchedAt.valueOf() === 1000, + ), + ).toBe(true); + await repository.stagePage(attempt, second, undefined, { + hasNext: false, + items: [], + }); + await repository.publish(attempt, [interval, second], new Date(3000)); + expect(await prisma.tbankOperation.count()).toBe(1); + expect( + (await prisma.tbankAccount.findMany()).map( + (row) => row.operationsRevision, + ), + ).toEqual([2, 2]); + await repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [operation('same-id', '-20')], + }); + await repository.publish(attempt, [interval], new Date(4000)); + expect( + ( + await prisma.tbankAccount.findFirstOrThrow({ + where: { accountId: 'a' }, + }) + ).operationsRevision, + ).toBe(2); + expect( + ( + await prisma.tbankOperationCoverage.findFirstOrThrow({ + where: { accountId: 'a' }, + }) + ).fetchedAt, + ).toEqual(new Date(4000)); + }); + + it('reconciles a changed date and removes cancelled operations without changing another account', async () => { + await repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [operation('moved'), operation('cancelled')], + }); + await repository.stagePage(attempt, second, undefined, { + hasNext: false, + items: [operation('cancelled')], + }); + await repository.publish(attempt, [interval, second], new Date(1000)); + await expect( + repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [ + operation('cancelled'), + { ...operation('cancelled'), state: 'OPERATION_STATE_CANCELED' }, + ], + }), + ).rejects.toThrow('Conflicting'); + const moved = { + ...operation('moved', '-123'), + date: { seconds: '1769000000' }, + state: 'OPERATION_STATE_EXECUTED', + }; + await repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [ + moved, + { ...operation('cancelled'), state: 'OPERATION_STATE_CANCELED' }, + ], + }); + expect(await prisma.tbankOperation.count()).toBe(3); + await repository.publish(attempt, [interval], new Date(2000)); + const rows = await prisma.tbankOperation.findMany({ + orderBy: { accountId: 'asc' }, + }); + expect(rows).toHaveLength(2); + expect(rows[0].payload).toEqual(moved); + expect(rows[0].occurredAt).toEqual(new Date(1769000000000)); + expect(rows[1].accountId).toBe('b'); + expect(rows[1].brokerOperationId).toBe('cancelled'); + }); + + it('rejects malformed pagination and conflicting identities, filters exact boundaries, and fences superseded work', async () => { + await expect( + repository.stagePage(attempt, interval, undefined, { + hasNext: true, + items: [], + }), + ).rejects.toThrow('continuation'); + await expect( + repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [{ ...operation(''), cursor: 'not-an-id' }], + }), + ).rejects.toThrow('identity'); + expect(await prisma.tbankSyncStage.count()).toBe(0); + const edge = { + ...operation('edge'), + date: { seconds: String(interval.to.valueOf() / 1000) }, + }; + await repository.stagePage(attempt, interval, undefined, { + hasNext: true, + nextCursor: 'next', + items: [operation('id'), operation('id'), edge], + }); + expect(await prisma.tbankOperation.count()).toBe(0); + await expect( + repository.stagePage(attempt, interval, 'next', { + hasNext: true, + nextCursor: 'next', + items: [], + }), + ).rejects.toThrow('continuation'); + await expect( + repository.stagePage(attempt, interval, 'next', { + hasNext: false, + items: [operation('id', '11')], + }), + ).rejects.toThrow('Conflicting'); + await repository.stagePage(attempt, interval, 'next', { + hasNext: false, + items: [operation('id')], + }); + await repository.publish(attempt, [interval], new Date()); + expect( + (await prisma.tbankOperation.findMany()).map( + (row) => row.brokerOperationId, + ), + ).toEqual(['id']); + await prisma.tbankSyncWork.update({ + where: { id: attempt.id }, + data: { attemptId: 'replacement' }, + }); + expect( + await repository.stagePage(attempt, second, undefined, { + hasNext: false, + items: [], + }), + ).toBe(false); + expect(await repository.publish(attempt, [second], new Date())).toBe(false); + expect(await prisma.tbankOperation.count()).toBe(1); + }); +}); diff --git a/test/tbank-operations-sync.e2e-spec.ts b/test/tbank-operations-sync.e2e-spec.ts new file mode 100644 index 0000000..8d6b813 --- /dev/null +++ b/test/tbank-operations-sync.e2e-spec.ts @@ -0,0 +1,714 @@ +import { ConfigService } from '@nestjs/config'; +import { fork } from 'node:child_process'; +import { once } from 'node:events'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import type { + TbankOperationsByCursorRequest, + TbankOperationsByCursorResponse, +} from '../src/integrations/tbank/tbank.client'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { BrokerageOperationsSyncService } from '../src/modules/investments/operations/application/brokerage-operations-sync.service'; + +function deferred() { + let resolve: (value: T) => void = () => { + throw new Error('Deferred not initialized'); + }; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +// External RPCs alone are controlled. Work claims, recovery, staging and +// financial publication use the actual migrated temporary SQLite. +describe('Request-driven operations synchronization (SQLite)', () => { + const prisma = new PrismaService( + new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), + ); + const repository = new BrokerageOperationsRepository(prisma); + const upstream = { + getOperationsByCursor: jest.fn< + Promise, + [string, TbankOperationsByCursorRequest] + >(), + getOpenAccounts: jest.fn(), + }; + const tokens = new TbankTokenService( + new ConfigService({ + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + }), + ); + const access = new TbankAccessService(prisma, tokens, upstream as never); + const logger = { event: jest.fn() }; + const createService = () => + new BrokerageOperationsSyncService( + repository, + access, + upstream as never, + logger as never, + ); + let service: BrokerageOperationsSyncService; + const january = { + accountId: 'account', + from: new Date('2026-01-01Z'), + to: new Date('2026-02-01Z'), + }; + const february = { + ...january, + from: january.to, + to: new Date('2026-03-01Z'), + }; + const operation = (value = '10', seconds = '1768000000') => ({ + id: 'id', + date: { seconds }, + payment: { units: value, nano: 1, currency: 'rub' }, + }); + const complete = { hasNext: false, items: [operation()] }; + async function state(id: string, expected: string) { + for (let index = 0; index < 500; index += 1) { + const work = await prisma.tbankSyncWork.findUniqueOrThrow({ + where: { id }, + }); + if (work.state === expected) return work; + await delay(2); + } + throw new Error(`Sync did not reach ${expected}`); + } + beforeAll(async () => { + await prisma.$connect(); + await prisma.user.create({ + data: { + id: 'owner', + email: 'owner@example.invalid', + passwordHash: 'synthetic', + }, + }); + }); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + jest.clearAllMocks(); + upstream.getOperationsByCursor.mockReset(); + await prisma.tbankConnection.create({ + data: { + userId: 'owner', + revision: 'revision', + encryptedToken: tokens.encrypt('synthetic'), + connectedAt: new Date(), + membershipAt: new Date(), + accounts: { + create: { + accountId: 'account', + upstreamOrder: 0, + name: 'Synthetic', + type: 'BROKERAGE', + }, + }, + }, + }); + service = createService(); + }); + afterAll(async () => prisma.$disconnect()); + + it('joins overlapping account and instrument refresh into one atomic publication group', async () => { + const gate = deferred(); + upstream.getOperationsByCursor + .mockReturnValueOnce(gate.promise) + .mockResolvedValue({ + hasNext: false, + items: [{ ...operation(), instrumentUid: 'uid' }], + }); + const subset = requiredHandle( + await service.request( + 'owner', + [{ ...january, instrumentUid: 'uid' }], + true, + ), + ); + await delay(5); + const whole = requiredHandle( + await service.request('owner', [january], true), + ); + expect(whole).toBe(subset); + expect(await prisma.tbankOperation.count()).toBe(0); + gate.resolve({ + hasNext: false, + items: [{ ...operation(), instrumentUid: 'uid' }], + }); + await state(whole, 'complete'); + expect(await prisma.tbankOperation.count()).toBe(1); + expect(await prisma.tbankOperationCoverage.count()).toBe(2); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); + }); + + it('publishes the forced visible period before unrelated pending history and retains it if history fails', async () => { + const gate = deferred(); + const march = { + ...january, + from: new Date('2026-03-01Z'), + to: new Date('2026-04-01Z'), + }; + upstream.getOperationsByCursor + .mockReturnValueOnce(gate.promise) + .mockImplementation((_token, request) => { + if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000) + return Promise.resolve({ + hasNext: false, + items: [operation('20', '1773000000')], + }); + return Promise.reject(new Error('unrelated history unavailable')); + }); + const history = requiredHandle( + await service.request('owner', [february, march], false, 'history'), + ); + await delay(5); + const screen = requiredHandle( + await service.request('owner', [march], true), + ); + expect(screen).toBe(history); + gate.resolve({ hasNext: false, items: [] }); + await state(screen, 'failed'); + expect( + (await prisma.tbankOperation.findMany()).map((row) => row.payload), + ).toEqual([operation('20', '1773000000')]); + const coverage = await repository.coverage('owner', 'revision', [ + 'account', + ]); + expect(coverage.map((range) => [range.from, range.to])).toEqual([ + [march.from, march.to], + ]); + }); + + it('moves a newly visible interval ahead of pending backfill and records an unknown requested floor', async () => { + const gate = deferred(); + upstream.getOperationsByCursor + .mockReturnValueOnce(gate.promise) + .mockResolvedValue({ hasNext: false, items: [] }); + const march = { + ...january, + from: new Date('2026-03-01Z'), + to: new Date('2026-04-01Z'), + }; + const history = requiredHandle( + await service.request( + 'owner', + [january, february, march], + false, + 'history', + ), + ); + await delay(5); + await service.request('owner', [march], false); + gate.resolve({ hasNext: false, items: [] }); + await state(history, 'complete'); + expect( + upstream.getOperationsByCursor.mock.calls.map( + ([, call]) => Number(call.from?.seconds) + 1, + ), + ).toEqual([ + january.from.valueOf() / 1000, + march.from.valueOf() / 1000, + february.from.valueOf() / 1000, + ]); + const account = await prisma.tbankAccount.findFirstOrThrow(); + expect(account.historyBoundary).toBe('unknown'); + expect(account.historyFrom).toEqual(new Date(0)); + }); + + it('never publishes coverage beyond the fixed request time', async () => { + const before = new Date(); + const from = new Date( + Date.UTC(before.getUTCFullYear(), before.getUTCMonth(), 1), + ); + const future = new Date( + Date.UTC(before.getUTCFullYear(), before.getUTCMonth() + 1, 1), + ); + upstream.getOperationsByCursor.mockResolvedValue({ + hasNext: false, + items: [], + }); + const handle = requiredHandle( + await service.request( + 'owner', + [{ accountId: 'account', from, to: future }], + false, + ), + ); + const accepted = new Date(); + await state(handle, 'complete'); + const saved = await prisma.tbankOperationCoverage.findFirstOrThrow(); + expect(saved.to.valueOf()).toBeGreaterThanOrEqual(before.valueOf()); + expect(saved.to.valueOf()).toBeLessThanOrEqual(accepted.valueOf()); + expect(saved.asOf).toEqual(saved.to); + }); + + it('uses persistent coverage without RPC, observes stale data without starting work, and refreshes on request', async () => { + const now = new Date(); + const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const asOf = new Date( + now.valueOf() - Math.min(1000, (now.valueOf() - from.valueOf()) / 2), + ); + await prisma.tbankOperationCoverage.create({ + data: { + userId: 'owner', + revision: 'revision', + accountId: 'account', + from, + to: asOf, + asOf, + fetchedAt: now, + }, + }); + const target = { accountId: 'account', from, to: now }; + const warm = await service.readPlan('owner', [target], { movingEnd: true }); + expect(warm.handle).toBeNull(); + expect(warm.plan?.intervals[0]).toMatchObject({ + availability: 'complete', + asOf, + stale: false, + }); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + await prisma.tbankOperationCoverage.updateMany({ + data: { fetchedAt: new Date(now.valueOf() - 300_000) }, + }); + const observed = await service.readPlan('owner', [target], { + movingEnd: true, + observe: true, + }); + expect(observed.handle).toBeNull(); + expect(observed.plan?.intervals[0]).toMatchObject({ + availability: 'complete', + asOf, + stale: true, + }); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + upstream.getOperationsByCursor.mockResolvedValue({ + hasNext: false, + items: [], + }); + const refresh = await service.readPlan('owner', [target], { + movingEnd: true, + }); + await state(requiredHandle(refresh.handle), 'complete'); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + expect( + ( + await service.readPlan('owner', [{ ...target, to: new Date() }], { + movingEnd: true, + }) + ).handle, + ).toBeNull(); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + const forced = await service.readPlan( + 'owner', + [{ ...target, to: new Date() }], + { movingEnd: true, force: true }, + ); + await state(requiredHandle(forced.handle), 'complete'); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); + }); + + it('preserves the entire complete quarter across accounts when the second month fails', async () => { + await prisma.tbankAccount.create({ + data: { + userId: 'owner', + revision: 'revision', + accountId: 'second', + name: 'Second', + type: 'BROKERAGE', + upstreamOrder: 1, + }, + }); + const quarter = [ + { ...january, to: new Date('2026-04-01Z') }, + { ...january, accountId: 'second', to: new Date('2026-04-01Z') }, + ]; + let amount = '10'; + let failFebruary = false; + upstream.getOperationsByCursor.mockImplementation((_token, request) => { + if ( + failFebruary && + request.from?.seconds === String(february.from.valueOf() / 1000 - 1) + ) + return Promise.reject(new Error('Second month unavailable')); + return Promise.resolve({ + hasNext: false, + items: [ + { + ...operation(amount, String(Number(request.from?.seconds) + 2)), + id: `payment-${request.from?.seconds}`, + }, + ], + }); + }); + const handle = requiredHandle( + await service.request('owner', quarter, false), + ); + await state(handle, 'complete'); + const before = await prisma.tbankOperation.findMany({ + orderBy: [{ accountId: 'asc' }, { brokerOperationId: 'asc' }], + }); + const coverage = await prisma.tbankOperationCoverage.findMany(); + expect(before).toHaveLength(6); + amount = '-20'; + failFebruary = true; + await service.request('owner', quarter, false); + await state(handle, 'failed'); + expect( + await prisma.tbankOperation.findMany({ + orderBy: [{ accountId: 'asc' }, { brokerOperationId: 'asc' }], + }), + ).toEqual(before); + expect(await prisma.tbankOperationCoverage.findMany()).toEqual(coverage); + failFebruary = false; + await service.request('owner', quarter, false); + await state(handle, 'complete'); + const after = await prisma.tbankOperation.findMany(); + expect(after).toHaveLength(6); + expect( + after.every( + (row) => + (row.payload as { payment: { units: string } }).payment.units === + '-20', + ), + ).toBe(true); + expect( + (await prisma.tbankAccount.findMany()).map( + (account) => account.operationsRevision, + ), + ).toEqual([4, 4]); + }); + + it.each(['between-pages', 'before-publish', 'after-publish'])( + 'survives SIGKILL %s without startup recovery or lost publication', + async (checkpoint) => { + const child = fork( + join(__dirname, 'fixtures/tbank-sync-crash.cjs'), + [checkpoint], + { silent: true }, + ); + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const exited = once(child, 'exit'); + try { + const handle = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Crash checkpoint timeout: ${stderr}`)), + 5000, + ); + child.once('exit', () => { + clearTimeout(timer); + reject(new Error(`Worker exited early: ${stderr}`)); + }); + child.on('message', (message: unknown) => { + if ( + message && + typeof message === 'object' && + 'handle' in message && + typeof message.handle === 'string' + ) { + clearTimeout(timer); + resolve(message.handle); + } + if (message && typeof message === 'object' && 'error' in message) { + clearTimeout(timer); + reject(new Error(String(message.error))); + } + }); + }); + child.kill('SIGKILL'); + await exited; + service = createService(); + const saved = await service.observe('owner', handle); + expect(saved.state).toBe( + checkpoint === 'after-publish' ? 'complete' : 'running', + ); + await delay(10); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + if (checkpoint === 'after-publish') { + expect(await prisma.tbankOperation.count()).toBe(1); + expect(await prisma.tbankSyncStage.count()).toBe(0); + } else { + expect(await prisma.tbankOperation.count()).toBe(0); + expect(await prisma.tbankSyncStage.count()).toBeGreaterThan(0); + upstream.getOperationsByCursor.mockResolvedValueOnce(complete); + await service.request('owner', [january], true); + await state(handle, 'complete'); + expect( + upstream.getOperationsByCursor.mock.calls[0][1].cursor, + ).toBeUndefined(); + expect(await prisma.tbankOperation.count()).toBe(1); + } + } finally { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + await exited; + } + }, + ); + + it('joins a request bridging active groups and keeps both original handles observable', async () => { + const blocked = deferred(); + upstream.getOperationsByCursor + .mockReturnValueOnce(blocked.promise) + .mockResolvedValue({ hasNext: false, items: [] }); + const early = { ...january, to: new Date('2026-01-10Z') }; + const late = { ...january, from: new Date('2026-01-20Z') }; + const first = requiredHandle(await service.request('owner', [early], true)); + await delay(5); + const second = requiredHandle(await service.request('owner', [late], true)); + const merged = requiredHandle( + await service.request('owner', [january], true), + ); + expect([first, second]).toContain(merged); + blocked.resolve({ hasNext: false, items: [] }); + await state(merged, 'complete'); + expect((await service.observe('owner', first)).state).toBe('complete'); + expect((await service.observe('owner', second)).state).toBe('complete'); + expect(await prisma.tbankOperationCoverage.count()).toBe(1); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); + }); + + it('extends an overlapping atomic group without rereading its intersection or publishing a partial result', async () => { + const first = deferred(); + const remainder = deferred(); + const early = { ...january, to: new Date('2026-01-20Z') }; + const late = { ...january, from: new Date('2026-01-10Z') }; + upstream.getOperationsByCursor + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(remainder.promise); + const handle = requiredHandle( + await service.request('owner', [early], true), + ); + await delay(5); + expect(await service.request('owner', [late], true)).toBe(handle); + first.resolve({ hasNext: false, items: [] }); + for ( + let index = 0; + index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; + index += 1 + ) + await delay(2); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); + expect(upstream.getOperationsByCursor.mock.calls[1][1].from).toEqual({ + seconds: String(early.to.valueOf() / 1000 - 1), + }); + expect(await prisma.tbankOperationCoverage.count()).toBe(0); + remainder.resolve({ hasNext: false, items: [] }); + await state(handle, 'complete'); + expect(await prisma.tbankOperationCoverage.count()).toBe(2); + }); + + it('lets new screen work overtake history between provider pages', async () => { + const first = deferred<{ + hasNext: boolean; + nextCursor: string; + items: never[]; + }>(); + upstream.getOperationsByCursor + .mockReturnValueOnce(first.promise) + .mockResolvedValue({ hasNext: false, items: [] }); + const history = requiredHandle( + await service.request('owner', [january], false, 'history'), + ); + await delay(5); + const screen = requiredHandle( + await service.request('owner', [february], true), + ); + first.resolve({ hasNext: true, nextCursor: 'next', items: [] }); + await state(screen, 'complete'); + await state(history, 'complete'); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(3); + expect(upstream.getOperationsByCursor.mock.calls[1][1].from).toEqual({ + seconds: String(february.from.valueOf() / 1000 - 1), + }); + expect(upstream.getOperationsByCursor.mock.calls[2][1].cursor).toBe('next'); + }); + + it('keeps published initial windows on recovery and restarts only the unfinished month', async () => { + const blocked = deferred(); + upstream.getOperationsByCursor + .mockResolvedValueOnce(complete) + .mockReturnValueOnce(blocked.promise); + const handle = requiredHandle( + await service.request('owner', [january, february], false), + ); + for ( + let index = 0; + index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; + index += 1 + ) + await delay(2); + expect(await prisma.tbankOperation.count()).toBe(1); + const before = await prisma.tbankOperationCoverage.findMany(); + expect(before).toHaveLength(1); + service = createService(); + upstream.getOperationsByCursor.mockResolvedValueOnce({ + hasNext: false, + items: [], + }); + await service.request('owner', [january, february], false); + await state(handle, 'complete'); + expect(upstream.getOperationsByCursor.mock.calls[2][1]).toMatchObject({ + from: { seconds: String(february.from.valueOf() / 1000 - 1) }, + }); + expect( + ( + await prisma.tbankOperationCoverage.findFirstOrThrow({ + where: { from: january.from }, + }) + ).fetchedAt, + ).toEqual(before[0].fetchedAt); + blocked.resolve(complete); + await delay(10); + expect(await prisma.tbankOperation.count()).toBe(1); + expect(await prisma.tbankOperationCoverage.count()).toBe(2); + }); + + it('persists current invalid credentials and rejects work for an inaccessible account before RPC', async () => { + await expect( + service.request('owner', [{ ...january, accountId: 'other' }], true), + ).rejects.toMatchObject({ status: 404 }); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + upstream.getOperationsByCursor.mockRejectedValueOnce( + new Error('INVALID_TBANK_TOKEN'), + ); + const handle = requiredHandle( + await service.request('owner', [january], true), + ); + expect((await state(handle, 'failed')).errorCode).toBe( + 'INVALID_TBANK_TOKEN', + ); + expect( + ( + await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }) + ).invalidToken, + ).toBe(true); + await expect( + service.request('owner', [january], true), + ).rejects.toMatchObject({ status: 400 }); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + }); + + it('coalesces twenty requests, observes without RPC and completes only after the last page', async () => { + const blocked = deferred(); + upstream.getOperationsByCursor.mockReturnValueOnce(blocked.promise); + const handles = await Promise.all( + Array.from({ length: 20 }, () => + service.request('owner', [january], true), + ), + ); + expect(new Set(handles).size).toBe(1); + const handle = requiredHandle(handles[0]); + await delay(5); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + expect((await service.observe('owner', handle)).state).toBe('running'); + expect(await prisma.tbankOperation.count()).toBe(0); + blocked.resolve(complete); + await state(handle, 'complete'); + expect(await prisma.tbankOperation.count()).toBe(1); + await Promise.all( + Array.from({ length: 20 }, () => service.observe('owner', handle)), + ); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + expect(upstream.getOperationsByCursor.mock.calls[0][1]).toMatchObject({ + from: { seconds: String(january.from.valueOf() / 1000 - 1) }, + to: { seconds: String(january.to.valueOf() / 1000 + 1) }, + state: 'OPERATION_STATE_EXECUTED', + limit: 1000, + }); + }); + + it('keeps the full old result on a second-month failure, has no retry loop and resumes only on request', async () => { + upstream.getOperationsByCursor + .mockResolvedValueOnce(complete) + .mockResolvedValueOnce({ hasNext: false, items: [] }); + const handle = requiredHandle( + await service.request('owner', [january, february], false), + ); + await state(handle, 'complete'); + const oldCoverage = await prisma.tbankOperationCoverage.findMany(); + upstream.getOperationsByCursor + .mockResolvedValueOnce({ hasNext: false, items: [operation('-20')] }) + .mockRejectedValueOnce(new Error('synthetic outage')); + await service.request('owner', [january, february], false); + await state(handle, 'failed'); + expect((await prisma.tbankOperation.findFirstOrThrow()).payload).toEqual( + operation(), + ); + expect(await prisma.tbankOperationCoverage.findMany()).toEqual(oldCoverage); + const calls = upstream.getOperationsByCursor.mock.calls.length; + await service.observe('owner', handle); + await delay(10); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(calls); + upstream.getOperationsByCursor + .mockResolvedValueOnce({ hasNext: false, items: [operation('-20')] }) + .mockResolvedValueOnce({ hasNext: false, items: [] }); + await service.request('owner', [january, february], false); + await state(handle, 'complete'); + expect((await prisma.tbankOperation.findFirstOrThrow()).payload).toEqual( + operation('-20'), + ); + }); + + it('starts an unfinished interval from page one after a new run request and ignores late old pages', async () => { + const blocked = deferred(); + upstream.getOperationsByCursor + .mockResolvedValueOnce({ + hasNext: true, + nextCursor: 'provider-cursor', + items: [operation()], + }) + .mockReturnValueOnce(blocked.promise); + const handle = requiredHandle( + await service.request('owner', [january], true), + ); + for ( + let index = 0; + index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; + index += 1 + ) + await delay(2); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); + const before = await prisma.tbankSyncWork.findUniqueOrThrow({ + where: { id: handle }, + }); + service = createService(); + await service.observe('owner', handle); + await delay(5); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); + upstream.getOperationsByCursor.mockResolvedValueOnce({ + hasNext: false, + items: [operation('30')], + }); + await service.request('owner', [january], true); + await state(handle, 'complete'); + const after = await prisma.tbankSyncWork.findUniqueOrThrow({ + where: { id: handle }, + }); + expect(after.attemptId).not.toBe(before.attemptId); + expect(after.processRunId).not.toBe(before.processRunId); + expect( + upstream.getOperationsByCursor.mock.calls[2][1].cursor, + ).toBeUndefined(); + blocked.resolve(complete); + await delay(10); + expect((await prisma.tbankOperation.findFirstOrThrow()).payload).toEqual( + operation('30'), + ); + expect(await prisma.tbankSyncStage.count()).toBe(0); + }); +}); + +function requiredHandle(value: string | null): string { + if (!value) throw new Error('Expected sync handle'); + return value; +} diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts new file mode 100644 index 0000000..27ae001 --- /dev/null +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -0,0 +1,569 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import type { Server } from 'node:http'; +import { cpus, totalmem } from 'node:os'; +import { writeFileSync, statSync, readFileSync } from 'node:fs'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { Request, Response, NextFunction } from 'express'; +import { performance } from 'node:perf_hooks'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { + TbankClient, + TbankOperationsByCursorRequest, +} from '../src/integrations/tbank/tbank.client'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { MoexClient } from '../src/integrations/moex/moex.api'; +import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; + +// Same navigation and synthetic sources as the saved baseline; isolated SQLite only. +describe('T-Bank persisted read budget', () => { + let app: INestApplication; + const owner = 'synthetic-baseline-owner'; + const accounts = Array.from({ length: 3 }, (_, index) => ({ + id: `baseline-${index}`, + name: `Synthetic ${index}`, + type: 'ACCOUNT_TYPE_TINKOFF', + openedDate: { seconds: Date.UTC(2016, 8, 1) / 1000 }, + })); + const instruments = Array.from({ length: 200 }, (_, index) => ({ + uid: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + ticker: `SYNTH${index}`, + name: `Synthetic instrument ${index}`, + assetUid: `asset-${index}`, + isin: `SYNTHETIC${index}`, + currency: 'rub', + instrumentKind: + index % 2 ? 'INSTRUMENT_TYPE_BOND' : 'INSTRUMENT_TYPE_SHARE', + classCode: index % 2 ? 'TQOB' : 'TQBR', + apiTradeAvailableFlag: true, + first_1dayCandleDate: { seconds: Date.UTC(2026, 7, 31 - 4999) / 1000 }, + nominal: { currency: 'rub', units: '1000' }, + aciValue: { currency: 'rub', units: '12', nano: 345678900 }, + })); + const candles = Array.from({ length: 5000 }, (_, index) => ({ + time: { seconds: Date.UTC(2026, 7, 31 - 4999 + index) / 1000 }, + close: { units: '100', nano: index }, + isComplete: true, + })); + const operations = Array.from({ length: 100_000 }, (_, index) => { + // 1,000 measured-month rows, 200 income receipts, remaining rows over 10 years. + const time = + index < 1000 + ? Date.UTC(2026, 7, 1) + index * 2_500_000 + : Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; + const instrument = instruments[index % instruments.length]; + return { + id: `operation-${index}`, + date: { seconds: String(Math.floor(time / 1000)) }, + type: index % 5 === 0 ? 'OPERATION_TYPE_COUPON' : 'OPERATION_TYPE_BUY', + instrumentUid: instrument.uid, + ticker: instrument.ticker, + instrumentType: 'bond', + payment: { currency: 'rub', units: '123', nano: 123456789 }, + quantity: '1', + quantityDone: '1', + }; + }).sort((a, b) => Number(b.date.seconds) - Number(a.date.seconds)); + const rpc: Record = {}; + let upstreamMode: 'ready' | 'delayed' | 'failed' = 'ready'; + const serverTimes: number[] = []; + async function counted(name: string, value: T): Promise { + rpc[name] = (rpc[name] ?? 0) + 1; + if (upstreamMode === 'delayed') await delay(2000); + if (upstreamMode === 'failed') + return Promise.reject(new Error('Synthetic outage')); + return Promise.resolve(value); + } + const upstream = { + getOpenAccounts: () => counted('accounts', accounts), + getPortfolio: () => + counted('portfolio', { + positions: instruments.map((instrument, index) => ({ + instrumentUid: instrument.uid, + instrumentType: index % 2 ? 'bond' : 'share', + quantity: { units: '1' }, + currentPrice: { currency: 'rub', units: '1000' }, + })), + }), + getPositions: () => + counted('positions', { money: [{ currency: 'rub', units: '100' }] }), + getInstrumentByUid: (_token: string, uid: string) => + counted( + 'metadata', + instruments.find((instrument) => instrument.uid === uid), + ), + findShares: (_token: string, query: string) => + counted( + 'findShares', + instruments.filter( + (item) => + item.instrumentKind === 'INSTRUMENT_TYPE_SHARE' && + item.ticker.includes(query), + ), + ), + findBonds: (_token: string, query: string) => + counted( + 'findBonds', + instruments.filter( + (item) => + item.instrumentKind === 'INSTRUMENT_TYPE_BOND' && + item.ticker.includes(query), + ), + ), + getShare: (_token: string, uid: string) => + counted( + 'share', + instruments.find((item) => item.uid === uid), + ), + getBond: (_token: string, uid: string) => + counted( + 'bond', + instruments.find((item) => item.uid === uid), + ), + getLastPrice: (_token: string, uid: string) => + counted('lastPrice', { + instrumentUid: uid, + price: { units: '100' }, + time: { seconds: Date.UTC(2026, 7, 31) / 1000 }, + }), + getLastPrices: (_token: string, uids: string[]) => + counted( + 'lastPrices', + uids.map((uid) => ({ instrumentUid: uid, price: { units: '100' } })), + ), + getTradingStatus: () => + counted('tradingStatus', 'SECURITY_TRADING_STATUS_NORMAL_TRADING'), + getCandles: (_token: string, _uid: string, from: Date, to: Date) => + counted( + 'candles', + candles.filter( + (row) => + row.time.seconds * 1000 >= from.valueOf() && + row.time.seconds * 1000 <= to.valueOf(), + ), + ), + getAssetDescription: () => counted('assetDescription', 'Synthetic company'), + getFundamentals: () => + counted('fundamentals', { + currency: 'rub', + revenueTtm: 1_000_000, + peRatioTtm: 5, + }), + getDividends: () => + counted('dividends', [ + { + dividendNet: { currency: 'rub', units: '10' }, + recordDate: { seconds: Date.UTC(2026, 9, 1) / 1000 }, + }, + ]), + getBondCoupons: () => + counted('coupons', [ + { + payOneBond: { currency: 'rub', units: '10' }, + couponDate: { seconds: Date.UTC(2026, 9, 1) / 1000 }, + fixDate: { seconds: Date.UTC(2026, 8, 30) / 1000 }, + couponPeriod: 30, + }, + ]), + getOperationsByCursor: ( + _token: string, + query: TbankOperationsByCursorRequest, + ) => { + const rows = operations.filter( + (row) => + Number(row.date.seconds) >= Number(query.from?.seconds ?? 0) && + Number(row.date.seconds) <= Number(query.to?.seconds ?? Infinity), + ); + const offset = Number(query.cursor ?? 0); + const end = offset + (query.limit ?? 50); + return counted('operations', { + items: rows.slice(offset, end), + hasNext: end < rows.length, + nextCursor: end < rows.length ? String(end) : '', + }); + }, + }; + const token = new JwtService().sign( + { sub: owner, type: 'access' }, + { secret: process.env.JWT_SECRET }, + ); + + async function start() { + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TbankClient) + .useValue(upstream) + .overrideProvider(MoexClient) + .useValue({ + bondEvents: () => + counted('moexEvents', [ + { + type: 'coupon', + date: '2026-10-01', + value: '10', + currency: 'RUB', + }, + ]), + bondYield: () => counted('moexYield', null), + }) + .overrideProvider(NcrRatingsClient) + .useValue({ rating: () => counted('ncrRating', null) }) + .compile(); + app = module.createNestApplication(); + app.use((_req: Request, res: Response, next: NextFunction) => { + const start = performance.now(); + res.once('finish', () => serverTimes.push(performance.now() - start)); + next(); + }); + configureApp(app); + await app.init(); + await app.listen(0, '127.0.0.1'); + } + beforeAll(async () => { + await start(); + const prisma = app.get(PrismaService); + await prisma.user.create({ + data: { + id: owner, + email: 'baseline@example.invalid', + passwordHash: 'unused', + }, + }); + const connection = await prisma.tbankConnection.create({ + data: { + userId: owner, + encryptedToken: app.get(TbankTokenService).encrypt('synthetic-token'), + connectedAt: new Date(), + membershipAt: new Date(), + membershipRevision: 1, + }, + }); + const now = new Date(); + for (const [index, account] of accounts.entries()) { + const scope = { + userId: owner, + revision: connection.revision, + accountId: account.id, + }; + await prisma.tbankAccount.create({ + data: { + ...scope, + name: account.name, + type: 'BROKERAGE', + openedOn: '2016-09-01', + upstreamOrder: index, + historyFrom: new Date('2016-09-01Z'), + historyBoundary: 'openedOn', + }, + }); + for (let offset = 0; offset < operations.length; offset += 1000) + await prisma.tbankOperation.createMany({ + data: operations.slice(offset, offset + 1000).map((row) => ({ + ...scope, + brokerOperationId: row.id, + occurredAt: new Date(Number(row.date.seconds) * 1000), + instrumentUid: row.instrumentUid, + category: row.type === 'OPERATION_TYPE_COUPON' ? 'income' : 'trade', + payload: row, + })), + }); + await prisma.tbankOperationCoverage.create({ + data: { + ...scope, + from: new Date('2016-08-31T21:00:00Z'), + to: now, + fetchedAt: now, + asOf: now, + }, + }); + } + for (const instrument of instruments) { + await prisma.tbankInstrument.create({ + data: { + provider: 'tbank', + uid: instrument.uid, + ticker: instrument.ticker, + instrumentType: + instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' + ? 'share' + : 'bond', + metadata: instrument, + fetchedAt: now, + }, + }); + await prisma.$executeRawUnsafe( + `WITH RECURSIVE days(n) AS (SELECT 0 UNION ALL SELECT n+1 FROM days WHERE n<4999) + INSERT INTO TbankCandle(provider,instrumentUid,interval,time,close,payload,isComplete,fetchedAt) + SELECT 'tbank',?,'day',strftime('%Y-%m-%dT%H:%M:%fZ', (?+n*86400000)/1000.0, 'unixepoch'),'100','{}',1,? FROM days`, + instrument.uid, + candles[0].time.seconds * 1000, + now.toISOString(), + ); + const today = new Date(now); + today.setUTCHours(0, 0, 0, 0); + await prisma.tbankCandleCoverage.createMany({ + data: [ + { + provider: 'tbank', + instrumentUid: instrument.uid, + interval: 'day', + from: new Date(candles[0].time.seconds * 1000), + to: today, + isComplete: true, + fetchedAt: now, + }, + { + provider: 'tbank', + instrumentUid: instrument.uid, + interval: 'day', + from: today, + to: new Date(today.valueOf() + 86400000), + isComplete: false, + fetchedAt: now, + }, + ], + }); + } + expect(await prisma.tbankOperation.count()).toBe(300_000); + expect(await prisma.tbankCandle.count()).toBe(1_000_000); + }, 300_000); + afterAll(async () => app?.close()); + + it('measures the same navigation with 10 clients and 100 reads per family', async () => { + expect(operations).toHaveLength(100_000); + expect(candles).toHaveLength(5000); + const routes = [ + '/brokerage-accounts', + '/brokerage-accounts/baseline-0/portfolio', + '/brokerage-accounts/all/portfolio', + '/brokerage-accounts/baseline-0/operations?from=2026-08-01&to=2026-08-31&limit=50', + '/brokerage-accounts/all/operations?from=2026-08-01&to=2026-08-31&limit=50', + '/brokerage-accounts/baseline-0/income-analytics?from=2026-08-01&to=2026-08-31', + '/brokerage-accounts/all/income-analytics?from=2026-08-01&to=2026-08-31', + '/brokerage-accounts/baseline-0/events?from=2026-09-01&to=2026-10-31', + '/brokerage-accounts/all/events?from=2026-09-01&to=2026-10-31', + '/stocks?query=SYNTH', + '/bonds?query=SYNTH', + ...[ + ['stocks', instruments[0].uid, 'dividends'], + ['bonds', instruments[1].uid, 'coupons'], + ].flatMap(([kind, uid, payout]) => [ + `/${kind}/${uid}`, + `/${kind}/${uid}/quote`, + `/${kind}/${uid}/${kind === 'stocks' ? 'company' : 'metrics'}`, + `/${kind}/${uid}/candles?period=year`, + `/${kind}/${uid}/candles?period=all`, + `/${kind}/${uid}/${payout}`, + ]), + ]; + async function settled() { + for (let index = 0; index < 6000; index++) { + await delay(10); + if ( + !(await app + .get(PrismaService) + .tbankSyncWork.count({ where: { state: 'running' } })) + ) + return; + } + throw new Error('Import did not settle'); + } + async function fresh() { + const db = app.get(PrismaService); + const fetchedAt = new Date(); + await db.tbankConnection.updateMany({ + data: { membershipAt: fetchedAt }, + }); + await db.tbankPortfolio.updateMany({ data: { fetchedAt } }); + await db.tbankInstrumentSource.updateMany({ data: { fetchedAt } }); + await db.tbankCandleCoverage.updateMany({ + where: { isComplete: false }, + data: { fetchedAt }, + }); + } + for (let pass = 0; pass < 3; pass++) { + for (const route of routes) { + await request(app.getHttpServer()) + .get(`/api/v1${route}`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + await settled(); + } + await fresh(); + } + const warmupRpc = { ...rpc }; + for (const key of Object.keys(rpc)) rpc[key] = 0; + await app.close(); + await start(); + upstreamMode = 'failed'; + const evidence = []; + const responses: Record = {}; + for (const route of routes) { + const read = async () => { + const start = performance.now(); + const response = await request(app.getHttpServer()) + .get(`/api/v1${route}`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const body = response.body as { + meta: { availability: string }; + data: unknown; + }; + if (route.includes('candles?period=all')) + expect((body.data as { candles: unknown[] }).candles).toHaveLength( + 5000, + ); + expect({ route, availability: body.meta.availability }).toEqual({ + route, + availability: 'complete', + }); + if (!responses[route]) { + responses[route] = body; + writeFileSync( + '/tmp/wealthledger-browser-responses.json', + JSON.stringify(responses), + ); + } + return { + ms: performance.now() - start, + bytes: Buffer.byteLength(response.text), + }; + }; + await fresh(); + const cold = await read(); + serverTimes.length = 0; + const before = { ...rpc }; + const samples = []; + for (let batch = 0; batch < 10; batch += 1) + samples.push(...(await Promise.all(Array.from({ length: 10 }, read)))); + const clientTimes = samples + .map((sample) => sample.ms) + .sort((a, b) => a - b); + const times = [...serverTimes].sort((a, b) => a - b); + expect(times).toHaveLength(100); + evidence.push({ + route, + cold, + count: samples.length, + clientP95: clientTimes[94], + p50: times[49], + p95: times[94], + p99: times[98], + max: times[99], + maxBytes: Math.max(...samples.map((sample) => sample.bytes)), + rpc: Object.fromEntries( + Object.entries(rpc).map(([key, count]) => [ + key, + count - (before[key] ?? 0), + ]), + ), + }); + } + const result = { + phase: 'persistent', + runtime: process.version, + hardware: { + cpuQuota: + process.platform === 'linux' + ? readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() + : null, + memoryLimit: + process.platform === 'linux' + ? readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() + : null, + cpu: cpus()[0]?.model, + cpus: cpus().length, + memoryBytes: totalmem(), + }, + profile: { + accounts: 3, + operationsPerAccount: 100_000, + instruments: 200, + candlesPerInstrument: 5000, + upstream: + 'synthetic failing upstream after population; transport limiter excluded', + timing: + 'backend middleware ingress to response finish; client p95 separately', + limitation: + 'Synthetic transport boundary; cgroup fields report constraints when run in Docker. Cross-host latency is not compared to baseline.', + }, + evidence, + totalRpc: { ...rpc }, + warmupRpc, + databaseBytes: statSync( + String(process.env.DATABASE_URL).replace('file:', ''), + ).size, + }; + const baseline = JSON.parse( + readFileSync('test/fixtures/tbank-read-baseline.json', 'utf8'), + ) as { totalRpc: Record }; + const oldRpc = Object.values(baseline.totalRpc).reduce( + (sum, n) => sum + n, + 0, + ); + const newRpc = Object.values(rpc).reduce((sum, n) => sum + n, 0); + const output = { + ...result, + oldRpc, + newRpc, + reduction: 1 - newRpc / oldRpc, + }; + writeFileSync( + '/tmp/wealthledger-persistent-budget.json', + JSON.stringify(output, null, 2), + ); + console.log(JSON.stringify(output)); + // Delayed dependency cannot hold a data response or multiply one stale pass. + await fresh(); + await app.get(PrismaService).tbankPortfolio.updateMany({ + where: { accountId: 'baseline-0' }, + data: { fetchedAt: new Date(0) }, + }); + const beforeStale = { ...rpc }; + upstreamMode = 'delayed'; + const startStale = performance.now(); + await Promise.all( + Array.from({ length: 20 }, () => + request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/baseline-0/portfolio') + .set('Authorization', `Bearer ${token}`) + .expect(200), + ), + ); + const staleResponsesMs = performance.now() - startStale; + expect(staleResponsesMs).toBeLessThan(2000); + await settled(); + expect(rpc.portfolio - beforeStale.portfolio).toBe(1); + expect(rpc.positions - beforeStale.positions).toBe(1); + await app + .get(PrismaService) + .tbankPortfolio.deleteMany({ where: { accountId: 'baseline-0' } }); + const coldStart = performance.now(); + const missing = await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/baseline-0/portfolio') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect((missing.body as { data: unknown }).data).toBeNull(); + const coldImportMs = performance.now() - coldStart; + expect(coldImportMs).toBeLessThan(2000); + await settled(); + Object.assign(output, { + staleResponsesMs, + staleLogicalPass: { portfolio: 1, positions: 1, retries: 0 }, + coldImportMs, + }); + writeFileSync( + '/tmp/wealthledger-persistent-budget.json', + JSON.stringify(output, null, 2), + ); + expect(newRpc).toBe(0); + expect(evidence.filter((group) => group.p95 > 500)).toEqual([]); + expect(await app.get(PrismaService).tbankOperation.count()).toBe(300_000); + const idleRpc = { ...rpc }; + await delay(1100); + expect(rpc).toEqual(idleRpc); + }, 300_000); +}); diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts new file mode 100644 index 0000000..5df8d13 --- /dev/null +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -0,0 +1,247 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto'; + +describe('screen sync HTTP and SQLite', () => { + let app: INestApplication; + let prisma: PrismaService; + const upstream = { + getOpenAccounts: jest.fn(), + getPortfolio: jest.fn(), + getPositions: jest.fn(), + getOperationsByCursor: jest.fn(), + findInstrument: jest.fn(), + }; + const auth = (id = 'owner') => + `Bearer ${new JwtService().sign({ sub: id, type: 'access' }, { secret: process.env.JWT_SECRET })}`; + const post = (scope: object, id = 'owner') => + request(app.getHttpServer()) + .post('/api/v1/tbank-sync') + .set('Authorization', auth(id)) + .send(scope); + const status = (id: string, owner = 'owner') => + request(app.getHttpServer()) + .get(`/api/v1/tbank-sync/${id}`) + .set('Authorization', auth(owner)); + async function settled(id: string) { + for (let i = 0; i < 300; i++) { + const response = await status(id).expect(200); + const body = response.body as TbankSyncStatusDto; + if (body.state !== 'running') return body; + await delay(5); + } + throw new Error('Screen work did not settle'); + } + beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(TbankClient) + .useValue(upstream) + .compile(); + app = module.createNestApplication(); + configureApp(app); + await app.init(); + await app.listen(0, '127.0.0.1'); + prisma = app.get(PrismaService); + for (const id of ['owner', 'other']) + await prisma.user.create({ + data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, + }); + }); + beforeEach(async () => { + await prisma.tbankConnection.deleteMany(); + jest.resetAllMocks(); + upstream.getOpenAccounts.mockResolvedValue([]); + upstream.getPortfolio.mockResolvedValue({ positions: [] }); + upstream.getPositions.mockResolvedValue({ money: [] }); + for (const userId of ['owner', 'other']) + await prisma.tbankConnection.create({ + data: { + userId, + encryptedToken: app.get(TbankTokenService).encrypt('synthetic'), + connectedAt: new Date(), + membershipAt: new Date(), + membershipRevision: 1, + }, + }); + }); + afterAll(async () => app.close()); + it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => { + let release: (value: unknown[]) => void = () => {}; + upstream.getOpenAccounts.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const responses = await Promise.all( + Array.from({ length: 20 }, () => + post({ screen: 'accounts' }).expect(202), + ), + ); + const ids = responses.map( + (response) => (response.body as { id: string }).id, + ); + expect(new Set(ids).size).toBe(1); + expect((await status(ids[0]).expect(200)).body).toMatchObject({ + state: 'running', + scope: { screen: 'accounts' }, + }); + await status(ids[0], 'other').expect(404); + expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); + release([]); + const done = await settled(ids[0]); + expect(done.state).toBe('succeeded'); + expect(done.resources[0]).toMatchObject({ + resourceKey: 'accounts', + availability: 'complete', + }); + expect(JSON.stringify(done)).not.toMatch( + /revision|attemptId|encryptedToken|synthetic/, + ); + await status(ids[0]).expect(200); + expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); + it.each([ + { screen: 'accounts', userId: 'other' }, + { screen: 'accounts', accountId: 'all' }, + { screen: 'events' }, + { screen: 'stock', instrumentUid: 'not-uuid' }, + { screen: 'operations', accountId: 'all', limit: 1001 }, + { + screen: 'income', + accountId: 'all', + from: '2026-02-31', + to: '2026-03-01', + }, + { + screen: 'stock', + instrumentUid: '00000000-0000-4000-8000-000000000001', + candleRange: { from: '2026-02-01', to: '2026-01-01', jobs: [] }, + }, + ])('rejects malformed scope without reserving work: %j', async (scope) => { + const response = await post(scope).expect(400); + expect(response.headers['content-type']).toContain( + 'application/problem+json', + ); + const problem = response.body as { + code: string; + errors: { pointer: string; detail: string }[]; + }; + expect(problem.code).toBe('VALIDATION_ERROR'); + expect(problem.errors.length).toBeGreaterThan(0); + for (const error of problem.errors) { + expect(typeof error.pointer).toBe('string'); + expect(typeof error.detail).toBe('string'); + } + expect(await prisma.tbankSyncWork.count()).toBe(0); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + }); + it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => { + upstream.getOpenAccounts.mockRejectedValue( + new Error('upstream details never exposed'), + ); + const accepted = await post({ screen: 'accounts' }).expect(202); + const id = (accepted.body as { id: string }).id; + const failed = await settled(id); + expect(failed.state).toBe('failed'); + expect(failed.errorCode).toBe('TBANK_UNAVAILABLE'); + await status(id).expect(200); + expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); + await prisma.tbankSyncWork.update({ + where: { id }, + data: { + state: 'running', + errorCode: null, + processRunId: 'previous-process', + progress: { children: [], dispatched: false }, + }, + }); + expect((await status(id).expect(200)).body).toMatchObject({ + state: 'cancelled', + }); + expect(upstream.getOpenAccounts).toHaveBeenCalledTimes(1); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + await status(id).expect(404); + }); + it('refreshes only the applied operation period and reports empty coverage changes without changing data revision', async () => { + const account = { + id: 'a', + name: 'Synthetic', + type: 'ACCOUNT_TYPE_TINKOFF', + openedDate: { seconds: '1577836800' }, + }; + upstream.getOpenAccounts.mockResolvedValue([account]); + const connection = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + await prisma.tbankAccount.create({ + data: { + userId: 'owner', + revision: connection.revision, + accountId: 'a', + name: 'Synthetic', + type: 'ACCOUNT_TYPE_TINKOFF', + openedOn: '2020-01-01', + upstreamOrder: 0, + }, + }); + const path = + '/api/v1/brokerage-accounts/a/operations?from=2025-01-01&to=2025-01-31&sync=observe'; + const before = await request(app.getHttpServer()) + .get(path) + .set('Authorization', auth()) + .expect(200); + upstream.getOperationsByCursor.mockResolvedValue({ + items: [], + hasNext: false, + }); + const response = await post({ + screen: 'operations', + accountId: 'a', + from: '2025-01-01', + to: '2025-01-31', + }).expect(202); + const done = await settled((response.body as { id: string }).id); + expect(done.state).toBe('succeeded'); + expect(upstream.getPortfolio).not.toHaveBeenCalled(); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + const after = await request(app.getHttpServer()) + .get(path) + .set('Authorization', auth()) + .expect(200); + const first = before.body as { + meta: { dataRevision: string; stateVersion: string }; + }; + const last = after.body as { + data: { items: unknown[] }; + meta: { + dataRevision: string; + stateVersion: string; + sync: { id: string }; + }; + }; + expect(last.data.items).toEqual([]); + expect(last.meta.dataRevision).toBe(first.meta.dataRevision); + expect(last.meta.stateVersion).not.toBe(first.meta.stateVersion); + expect(done.resources[0].stateVersion).toBe(last.meta.stateVersion); + await status(last.meta.sync.id).expect(200); + expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + }); + it('rejects known foreign accounts locally and requires authentication', async () => { + await post({ screen: 'operations', accountId: 'foreign' }).expect(404); + await request(app.getHttpServer()) + .post('/api/v1/tbank-sync') + .send({ screen: 'accounts' }) + .expect(401); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + }); +}); diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 26ceabb..c173dcf 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -2,111 +2,499 @@ import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import type { Server } from 'node:http'; +import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; import { AppModule } from '../src/app.module'; -import { MoexClient } from '../src/integrations/moex/moex.client'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { + TbankClient, + TbankTokenService, +} from '../src/integrations/tbank/tbank.api'; +import { MoexClient } from '../src/integrations/moex/moex.api'; +import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; +import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository'; +import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto'; +import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; +import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; -describe('Upcoming events date filter HTTP boundary', () => { +const shareUid = '00000000-0000-4000-8000-000000000001'; +const bondUid = '00000000-0000-4000-8000-000000000002'; +const newUid = '00000000-0000-4000-8000-000000000003'; +const window = { from: '2099-02-01', to: '2099-02-28' }; +const time = (date: string) => ({ + seconds: String(Date.parse(`${date}T00:00:00Z`) / 1000), +}); +const position = (uid = shareUid, quantity = '2', type = 'STOCK') => ({ + instrumentUid: uid, + figi: uid, + ticker: type === 'STOCK' ? 'STOCK' : 'BOND', + displayName: 'Synthetic', + type, + quantity, + averagePurchasePrice: null, + currentPrice: null, + currentValue: null, + absoluteReturn: null, + isin: null, + logoName: null, +}); + +describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { let app: INestApplication; - const getPortfolio = jest.fn(); - const token = new JwtService().sign( - { sub: 'account-owner', type: 'access' }, - { secret: process.env.JWT_SECRET }, - ); + let prisma: PrismaService; + const upstream = { + getShare: jest.fn(), + getBond: jest.fn(), + getDividends: jest.fn(), + getBondCoupons: jest.fn(), + getCandles: jest.fn(), + getPortfolio: jest.fn(), + getPositions: jest.fn(), + getOpenAccounts: jest.fn(), + getInstrumentByUid: jest.fn(), + getInstrumentByFigi: jest.fn(), + }; + const moex = { bondEvents: jest.fn() }; + const auth = (userId: string) => + new JwtService().sign( + { sub: userId, type: 'access' }, + { secret: process.env.JWT_SECRET }, + ); + const get = (path: string, userId = 'owner') => + request(app.getHttpServer()) + .get(`/api/v1/${path}`) + .set('Authorization', `Bearer ${auth(userId)}`) + .timeout(1500); + const events = ( + observe = true, + scope = 'account', + userId = 'owner', + range = window, + ) => + get( + `brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`, + userId, + ); + async function settled() { + for (let i = 0; i < 500; i++) { + await delay(5); + if ( + !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) + ) { + await delay(10); + if ( + !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) + ) + return; + } + } + throw new Error('Forecast dependencies did not settle'); + } + async function portfolio( + positions = [position()], + userId = 'owner', + accountId = 'account', + ) { + const payload = { + lastSuccessfulAt: new Date().toISOString(), + isStale: false, + positions, + totalsByCurrency: [], + cashBalances: [], + structure: { currencies: [], unvaluedCategories: [] }, + }; + await prisma.tbankPortfolio.upsert({ + where: { + userId_revision_accountId: { + userId, + revision: `${userId}-revision`, + accountId, + }, + }, + create: { + userId, + revision: `${userId}-revision`, + accountId, + payload, + fetchedAt: new Date(), + }, + update: { + payload, + fetchedAt: new Date(), + dataRevision: { increment: 1 }, + }, + }); + } beforeAll(async () => { const module = await Test.createTestingModule({ imports: [AppModule] }) - .overrideProvider(TbankAccessService) - .useValue({ - withAccount: ( - _user: string, - _id: string, - handler: (ctx: unknown) => unknown, - ) => handler({ token: 'unused' }), - withSupportedAccounts: ( - _user: string, - handler: (ctx: unknown) => unknown, - ) => - handler({ - token: 'unused', - accounts: [ - { id: 'account', name: 'Main' }, - { id: 'second', name: 'Second' }, - ], - }), - }) .overrideProvider(TbankClient) - .useValue({ - getPortfolio, - getInstrumentByFigi: () => Promise.resolve(null), - }) + .useValue(upstream) .overrideProvider(MoexClient) - .useValue({ - bondEvents: () => - Promise.resolve([ - { type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' }, - { type: 'offer', date: '2099-02-28' }, - { type: 'offer', date: '2099-03-01' }, - ]), - }) + .useValue(moex) .compile(); app = module.createNestApplication(); configureApp(app); await app.init(); - }); - beforeEach(() => { - getPortfolio.mockReset().mockResolvedValue({ - positions: [ - { - figi: 'B', - ticker: 'BOND', - instrumentType: 'bond', - quantity: { units: 3 }, - }, - ], - }); - }); - afterAll(() => app.close()); - - describe.each(['account', 'all'])('%s account scope', (scope) => { - it('returns only the requested inclusive period and matching totals', async () => { - const response = await request(app.getHttpServer()) - .get( - `/api/v1/brokerage-accounts/${scope}/events?from=2099-02-01&to=2099-02-28`, - ) - .set('Authorization', `Bearer ${token}`) - .expect(200); - expect(response.body).toMatchObject({ - accountId: scope, - events: - scope === 'all' - ? [ - { date: '2099-02-01', account: { id: 'account' } }, - { date: '2099-02-01', account: { id: 'second' } }, - { date: '2099-02-28', account: { id: 'account' } }, - { date: '2099-02-28', account: { id: 'second' } }, - ] - : [{ date: '2099-02-01' }, { date: '2099-02-28' }], - incomeTotalsByCurrency: [ - { currency: 'RUB', value: scope === 'all' ? '12' : '6' }, - ], + await app.listen(0, '127.0.0.1'); + prisma = app.get(PrismaService); + for (const id of ['owner', 'other']) + await prisma.user.create({ + data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); - }); + }); + beforeEach(async () => { + await settled(); + await prisma.tbankConnection.deleteMany(); + await prisma.tbankInstrumentLookup.deleteMany(); + await prisma.tbankInstrument.deleteMany(); + jest.resetAllMocks(); + upstream.getShare.mockImplementation((_token: string, uid: string) => + Promise.resolve({ + uid, + ticker: 'STOCK', + name: 'Synthetic share', + currency: 'rub', + }), + ); + upstream.getBond.mockImplementation((_token: string, uid: string) => + Promise.resolve({ + uid, + ticker: 'BOND', + name: 'Synthetic bond', + currency: 'rub', + }), + ); + upstream.getInstrumentByUid.mockImplementation( + (_token: string, uid: string) => + Promise.resolve({ + uid, + ticker: uid === bondUid ? 'BOND' : 'STOCK', + name: 'Synthetic', + }), + ); + upstream.getCandles.mockResolvedValue([]); + upstream.getDividends.mockResolvedValue([ + { + recordDate: time('2099-02-01'), + dividendNet: { units: '10', nano: 1, currency: 'usd' }, + }, + { + recordDate: time('2099-02-10'), + dividendType: 'Cancelled', + dividendNet: { units: '999', currency: 'rub' }, + }, + ]); + upstream.getBondCoupons.mockResolvedValue([]); + moex.bondEvents.mockResolvedValue([ + { type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' }, + { type: 'offer', date: '2099-02-28' }, + { type: 'maturity', date: '2099-02-28', value: '500', currency: 'RUB' }, + { type: 'offer', date: '2099-03-01' }, + ]); + for (const userId of ['owner', 'other']) { + await prisma.tbankConnection.create({ + data: { + userId, + revision: `${userId}-revision`, + encryptedToken: app + .get(TbankTokenService) + .encrypt(`synthetic-${userId}`), + connectedAt: new Date(), + membershipAt: new Date(), + membershipRevision: 1, + accounts: { + create: { + accountId: 'account', + type: 'ACCOUNT_TYPE_TINKOFF', + name: 'Synthetic', + upstreamOrder: 0, + }, + }, + }, + }); + await portfolio([position()], userId); + } + }); + afterAll(async () => { + await settled(); + await app?.close(); + }); - it.each([ - 'from=not-a-date', - 'from=2099-02-30', - 'to=2099-13-01', - 'from=2099-02-01T00:00:00Z', - 'from=2099-02-28&to=2099-02-01', - ])('rejects invalid dates before upstream access: %s', async (query) => { - await request(app.getHttpServer()) - .get(`/api/v1/brokerage-accounts/${scope}/events?${query}`) - .set('Authorization', `Bearer ${token}`) - .expect(400); - expect(getPortfolio).not.toHaveBeenCalled(); + it('returns unknown immediately, keeps observe passive and shares confirmed empty calendars across users', async () => { + let release: (value: unknown[]) => void = () => {}; + upstream.getDividends.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + expect( + ((await events().expect(200)).body as UpcomingEventsResourceDto).data, + ).toBeNull(); + expect(upstream.getDividends).not.toHaveBeenCalled(); + const cold = (await events(false).expect(200)) + .body as UpcomingEventsResourceDto; + expect(cold.data).toBeNull(); + for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) + await delay(5); + await events().expect(200); + release([]); + await settled(); + const warm = (await events().expect(200)).body as UpcomingEventsResourceDto; + expect(warm.meta.availability).toBe('complete'); + expect(warm.data?.events).toEqual([]); + const other = (await events(false, 'account', 'other').expect(200)) + .body as UpcomingEventsResourceDto; + expect(other.data?.events).toEqual([]); + expect(upstream.getDividends).toHaveBeenCalledTimes(1); + expect(await prisma.tbankForecast.count()).toBe(2); + expect(JSON.stringify(warm)).not.toContain('owner-revision'); + }); + + it('preserves source identity, inclusive dates, exact currencies, cancellation and offers in account/all scopes', async () => { + await portfolio([position(), position(bondUid, '3', 'BOND')]); + await events(false).expect(200); + await settled(); + const single = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + expect(single.data?.events).toHaveLength(4); + expect( + single.data?.events.find((event) => event.type === 'offer') + ?.projectedAmount, + ).toBeNull(); + expect(single.data?.incomeTotalsByCurrency).toEqual([ + { currency: 'RUB', value: '6' }, + { currency: 'USD', value: '20.000000002' }, + ]); + const all = (await events(true, '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(moex.bondEvents).toHaveBeenCalledWith('BOND'); + expect(upstream.getBondCoupons).not.toHaveBeenCalled(); + expect( + JSON.stringify(await prisma.tbankInstrumentSource.findMany()), + ).not.toMatch(/quantity|projectedAmount|estimatedAmount|synthetic-owner/); + expect(upstream.getPortfolio).not.toHaveBeenCalled(); + }); + + it('keeps the previous full forecast and timestamps after a new holding calendar fails, including reopen', async () => { + await events(false).expect(200); + await settled(); + const before = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + await portfolio([position(shareUid, '7'), position(newUid, '5')]); + upstream.getDividends.mockImplementation((_token: string, uid: string) => + uid === newUid + ? Promise.reject(new Error('new calendar unavailable')) + : Promise.resolve([]), + ); + const pending = (await events(false).expect(200)) + .body as UpcomingEventsResourceDto; + expect(pending.data).toEqual(before.data); + await settled(); + await prisma.$disconnect(); + const after = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + expect(after.data).toEqual(before.data); + expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); + expect(after.meta.sync.state).toBe('failed'); + const count = upstream.getDividends.mock.calls.length; + await events().expect(200); + expect(upstream.getDividends).toHaveBeenCalledTimes(count); + }); + + it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => { + await events(false).expect(200); + await settled(); + await events().expect(200); + const newRange = ( + await events(true, 'account', 'owner', { + from: '2099-03-01', + to: '2099-03-31', + }).expect(200) + ).body as UpcomingEventsResourceDto; + expect(newRange.data).toBeNull(); + await prisma.tbankConnection.update({ + where: { userId: 'owner' }, + data: { membershipRevision: { increment: 1 } }, }); + await portfolio([position(newUid)]); + expect( + ((await events().expect(200)).body as UpcomingEventsResourceDto).data, + ).toBeNull(); + await events(true, 'foreign').expect(404); + expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); + }); + + it('refuses a calculated snapshot if its portfolio changes before atomic publication', async () => { + await events(false).expect(200); + await settled(); + const before = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + await portfolio([position(shareUid, '3')]); + const repository = app.get(ForecastRepository); + const original = repository.resolve.bind(repository); + const publish = jest + .spyOn(repository, 'resolve') + .mockImplementationOnce(async (input) => { + await portfolio([position(shareUid, '9')]); + return original(input); + }); + try { + expect( + ((await events().expect(200)).body as UpcomingEventsResourceDto).data, + ).toEqual(before.data); + } finally { + publish.mockRestore(); + } + expect( + ((await events().expect(200)).body as UpcomingEventsResourceDto).data + ?.incomeTotalsByCurrency, + ).toEqual([{ currency: 'USD', value: '90.000000009' }]); + }); + + it('serves instrument payouts from shared calendars with private exact holdings and retains successful source timestamps on failure', async () => { + const date = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); + upstream.getDividends.mockResolvedValue([ + { + recordDate: time(date), + paymentDate: time(date), + dividendNet: { units: '10', nano: 1, currency: 'rub' }, + }, + ]); + await get(`stocks/${shareUid}/dividends`).expect(200); + await settled(); + const before = ( + await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200) + ).body as StockDividendsResourceDto; + expect(before.data?.dividends[0].estimatedAmount).toEqual({ + currency: 'RUB', + value: '20.000000002', + }); + await portfolio([position(shareUid, '3')], 'other'); + const other = ( + await get(`stocks/${shareUid}/dividends`, 'other').expect(200) + ).body as StockDividendsResourceDto; + expect(other.data?.dividends[0].estimatedAmount?.value).toBe( + '30.000000003', + ); + expect(upstream.getDividends).toHaveBeenCalledTimes(1); + upstream.getDividends.mockRejectedValue(new Error('outage')); + await app + .get(CatalogEnrichmentService) + .requestMany( + 'owner', + 'owner-revision', + 'share', + [shareUid], + true, + ['dividends'], + { from: before.data?.from ?? '', to: before.data?.to ?? '' }, + ); + await settled(); + expect( + ( + (await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200)) + .body as StockDividendsResourceDto + ).data, + ).toEqual(before.data); + await portfolio([position(bondUid, '3', 'BOND')]); + upstream.getBondCoupons.mockResolvedValue([ + { + couponDate: time(date), + fixDate: time(date), + payOneBond: { units: '2', currency: 'rub' }, + }, + ]); + await get(`bonds/${bondUid}/coupons`).expect(200); + await settled(); + const coupons = ( + await get(`bonds/${bondUid}/coupons?sync=observe`).expect(200) + ).body as BondCouponsResourceDto; + expect(coupons.data?.coupons[0].estimatedAmount).toEqual({ + currency: 'RUB', + value: '6', + }); + expect(moex.bondEvents).not.toHaveBeenCalled(); + }); + + it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => { + upstream.getBond.mockRejectedValue(new Error('metadata unavailable')); + await portfolio([position(bondUid, '3', 'BOND')]); + await events(false).expect(200); + await settled(); + const result = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + expect(result.data?.incomeTotalsByCurrency).toEqual([ + { currency: 'RUB', value: '6' }, + ]); + expect(moex.bondEvents).toHaveBeenCalledWith('BOND'); + }); + + it('does not turn a missing MOEX block into a complete empty forecast and defaults to a 30-day window', async () => { + await portfolio([position(bondUid, '3', 'BOND')]); + moex.bondEvents.mockRejectedValueOnce(new Error('missing MOEX block')); + await events(false).expect(200); + await settled(); + expect( + ((await events().expect(200)).body as UpcomingEventsResourceDto).data, + ).toBeNull(); + await get('brokerage-accounts/account/events?from=2099-02-01').expect(200); + await settled(); + const result = ( + await get( + 'brokerage-accounts/account/events?from=2099-02-01&sync=observe', + ).expect(200) + ).body as UpcomingEventsResourceDto; + expect(result.data?.events.map((event) => event.date)).toEqual([ + '2099-02-01', + '2099-02-28', + '2099-02-28', + '2099-03-01', + ]); + }); + + it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => { + let release: (value: unknown[]) => void = () => {}; + upstream.getDividends.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + await events(false).expect(200); + for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) + await delay(5); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + release([ + { + recordDate: time('2099-02-01'), + dividendNet: { units: '999', currency: 'rub' }, + }, + ]); + await delay(30); + expect( + await prisma.tbankInstrumentSource.count({ + where: { kind: 'dividends' }, + }), + ).toBe(0); + await events().expect(409); + expect(await prisma.tbankForecast.count()).toBe(0); + }); + + it.each([ + 'from=not-a-date', + 'from=2099-02-30', + 'to=2099-13-01', + 'from=2099-02-01T00:00:00Z', + 'from=2099-02-28&to=2099-02-01', + 'sync=invalid', + ])('rejects invalid query before source reads: %s', async (query) => { + await get(`brokerage-accounts/account/events?${query}`).expect(400); + expect(upstream.getDividends).not.toHaveBeenCalled(); }); }); -- 2.54.0 From e4232138d04ad6a2aa8100a7d36fdcb2d7663fa8 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 26 Sep 2026 21:45:29 +0300 Subject: [PATCH 2/2] test: prove reads finish before source synchronization Block synthetic upstream with an explicit barrier instead of comparing total HTTP batch time with a two-second timer. Keep the 500 ms server p95 gate and RPC assertions unchanged. --- test/tbank-read-baseline.e2e-spec.ts | 51 ++++++++++++++++++---------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts index 27ae001..56cf046 100644 --- a/test/tbank-read-baseline.e2e-spec.ts +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -70,10 +70,12 @@ describe('T-Bank persisted read budget', () => { }).sort((a, b) => Number(b.date.seconds) - Number(a.date.seconds)); const rpc: Record = {}; let upstreamMode: 'ready' | 'delayed' | 'failed' = 'ready'; + let sourceGate: Promise | undefined; + let releaseSource = () => {}; const serverTimes: number[] = []; async function counted(name: string, value: T): Promise { rpc[name] = (rpc[name] ?? 0) + 1; - if (upstreamMode === 'delayed') await delay(2000); + if (upstreamMode === 'delayed') await sourceGate; if (upstreamMode === 'failed') return Promise.reject(new Error('Synthetic outage')); return Promise.resolve(value); @@ -516,7 +518,8 @@ describe('T-Bank persisted read budget', () => { JSON.stringify(output, null, 2), ); console.log(JSON.stringify(output)); - // Delayed dependency cannot hold a data response or multiply one stale pass. + // Hold the source until HTTP reads finish: proves independence without a + // wall-clock race against the runner's throughput. The p95 gate stays below. await fresh(); await app.get(PrismaService).tbankPortfolio.updateMany({ where: { accountId: 'baseline-0' }, @@ -524,31 +527,45 @@ describe('T-Bank persisted read budget', () => { }); const beforeStale = { ...rpc }; upstreamMode = 'delayed'; + sourceGate = new Promise((resolve) => { + releaseSource = resolve; + }); const startStale = performance.now(); - await Promise.all( - Array.from({ length: 20 }, () => - request(app.getHttpServer()) - .get('/api/v1/brokerage-accounts/baseline-0/portfolio') - .set('Authorization', `Bearer ${token}`) - .expect(200), - ), - ); + try { + await Promise.all( + Array.from({ length: 20 }, () => + request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/baseline-0/portfolio') + .set('Authorization', `Bearer ${token}`) + .timeout(10_000) + .expect(200), + ), + ); + } finally { + releaseSource(); + } const staleResponsesMs = performance.now() - startStale; - expect(staleResponsesMs).toBeLessThan(2000); await settled(); expect(rpc.portfolio - beforeStale.portfolio).toBe(1); expect(rpc.positions - beforeStale.positions).toBe(1); await app .get(PrismaService) .tbankPortfolio.deleteMany({ where: { accountId: 'baseline-0' } }); + sourceGate = new Promise((resolve) => { + releaseSource = resolve; + }); const coldStart = performance.now(); - const missing = await request(app.getHttpServer()) - .get('/api/v1/brokerage-accounts/baseline-0/portfolio') - .set('Authorization', `Bearer ${token}`) - .expect(200); - expect((missing.body as { data: unknown }).data).toBeNull(); + try { + const missing = await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/baseline-0/portfolio') + .set('Authorization', `Bearer ${token}`) + .timeout(10_000) + .expect(200); + expect((missing.body as { data: unknown }).data).toBeNull(); + } finally { + releaseSource(); + } const coldImportMs = performance.now() - coldStart; - expect(coldImportMs).toBeLessThan(2000); await settled(); Object.assign(output, { staleResponsesMs, -- 2.54.0