Compare commits
19
Commits
v2.4.0
...
e3ecb8c15e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3ecb8c15e | ||
|
|
61c55256e7 | ||
|
|
cea77f6d56 | ||
|
|
b3d4801cfe | ||
|
|
b3ca11d141 | ||
|
|
efac7201e0 | ||
|
|
85a71967fa | ||
|
|
cd78b43783 | ||
|
|
deda7ffc2c | ||
|
|
5d31b3e6dd | ||
|
|
c5ccfeadea | ||
|
|
02e0d560c8 | ||
|
|
fa0a6e5603 | ||
|
|
bade5fae1c | ||
|
|
1a5de9bebd | ||
|
|
aeb4647fda | ||
|
|
169bee1da5 | ||
|
|
554410a271 | ||
|
|
a80bbbf1e2 |
+6
-6
@@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными
|
|||||||
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
|
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
|
||||||
чистые расчёты portfolio/banking остаются у domain owner.
|
чистые расчёты portfolio/banking остаются у domain owner.
|
||||||
|
|
||||||
PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные
|
PrismaService владеет DB lifecycle. Application services/use cases получают
|
||||||
через concrete repositories; существующие связные CRUD/transaction services
|
данные через concrete repositories; запросы и CAS живут в infrastructure
|
||||||
сохраняют прямые queries по [data convention](conventions/data.md).
|
по [data convention](conventions/data.md).
|
||||||
Money — currency + decimal string; расчёты используют точные общие helpers.
|
Money — currency + decimal string; расчёты используют точные общие helpers.
|
||||||
|
|
||||||
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
|
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
|
||||||
@@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio
|
|||||||
конкретные infrastructure providers, зарегистрированные module. Infrastructure
|
конкретные infrastructure providers, зарегистрированные module. Infrastructure
|
||||||
может использовать application types/input и domain rules; новые interfaces
|
может использовать application types/input и domain rules; новые interfaces
|
||||||
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
||||||
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
|
infrastructure. Application не строит Prisma queries: use case и связный CRUD
|
||||||
по [Data](conventions/data.md); use case получает данные через repository и сам
|
service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface
|
||||||
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
|
на каждую таблицу не требуется.
|
||||||
DTO остаются транспортными runtime classes; существующее использование DTO
|
DTO остаются транспортными runtime classes; существующее использование DTO
|
||||||
на application boundary допустимо по [HTTP API](conventions/http-api.md).
|
на application boundary допустимо по [HTTP API](conventions/http-api.md).
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas
|
|||||||
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
|
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
|
||||||
не становятся domain entities: расчёт получает только нужные ему значения.
|
не становятся domain entities: расчёт получает только нужные ему значения.
|
||||||
|
|
||||||
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction
|
Application-код не импортирует PrismaService и не строит Prisma queries.
|
||||||
services (auth, lifecycle подключения, изменения карт/операций/категорий)
|
Репозиторий владеет запросом и CAS-проверкой результата, а application —
|
||||||
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует
|
правилами и формированием ответа. Типы Prisma допустимы только в type-only
|
||||||
лишь набора pass-through методов. Новая самостоятельная read/write capability
|
импортах для существующей транзакционной границы. Не создавай обёртку
|
||||||
получает repository method; самостоятельный use case не выполняет Prisma queries.
|
на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write`
|
||||||
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для
|
сохраняет retry/transaction boundary для банковских записей; callback с
|
||||||
существующих banking services. Такой callback с Prisma transaction client —
|
transaction client — локальная техническая граница, не API для domain.
|
||||||
локальная техническая граница этих services, не API для domain или use cases.
|
|
||||||
|
|
||||||
Выбирай только нужные данные, сохраняй user/account ownership в запросах и
|
Выбирай только нужные данные, сохраняй user/account ownership в запросах и
|
||||||
application checks. Предварительный lookup не заменяет unique constraint при race.
|
application checks. Предварительный lookup не заменяет unique constraint при race.
|
||||||
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
|
Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет
|
||||||
для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case.
|
conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP
|
||||||
|
error contract. Сначала пойми invariant; не копируй его flow в другой use case.
|
||||||
|
|
||||||
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
|
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
|
||||||
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
|
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
- name: Audit runtime and development dependencies
|
- name: Audit runtime and development dependencies
|
||||||
run: |
|
run: |
|
||||||
npm audit --omit=dev --audit-level=low
|
npm audit --omit=dev --audit-level=low
|
||||||
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
|
npm audit --include=dev --audit-level=high
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
needs: [commits, security]
|
needs: [commits, security]
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
},
|
},
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 9
|
"count": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
|
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
|
||||||
|
|||||||
+3
-2
@@ -158,13 +158,14 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['src/**/*.use-case.ts'],
|
files: ['src/**/application/**/*.ts'],
|
||||||
ignores: tests,
|
ignores: tests,
|
||||||
rules: {
|
rules: {
|
||||||
'no-restricted-imports': ['error', {
|
'no-restricted-imports': ['error', {
|
||||||
patterns: [{
|
patterns: [{
|
||||||
group: prismaImports,
|
group: prismaImports,
|
||||||
message: 'Use cases obtain data through concrete repositories.',
|
allowTypeImports: true,
|
||||||
|
message: 'Application code obtains data through concrete repositories; type-only imports are allowed.',
|
||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
|||||||
+30
-1
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends": [
|
||||||
"config:recommended",
|
"config:recommended",
|
||||||
":semanticCommitTypeAll(chore)"
|
":semanticCommitTypeAll(chore)",
|
||||||
|
"security:minimumReleaseAgeNpm"
|
||||||
],
|
],
|
||||||
"timezone": "Europe/Moscow",
|
"timezone": "Europe/Moscow",
|
||||||
"labels": [
|
"labels": [
|
||||||
@@ -10,6 +11,13 @@
|
|||||||
],
|
],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"rangeStrategy": "pin",
|
"rangeStrategy": "pin",
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": [
|
||||||
|
"before 4am on the first day of the month"
|
||||||
|
]
|
||||||
|
},
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": [
|
"matchUpdateTypes": [
|
||||||
@@ -18,6 +26,27 @@
|
|||||||
],
|
],
|
||||||
"groupName": "non-major dependencies"
|
"groupName": "non-major dependencies"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": [
|
||||||
|
"github-actions"
|
||||||
|
],
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch"
|
||||||
|
],
|
||||||
|
"groupName": "non-major github actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": [
|
||||||
|
"docker-compose",
|
||||||
|
"dockerfile"
|
||||||
|
],
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch"
|
||||||
|
],
|
||||||
|
"groupName": "non-major container images"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": [
|
"matchUpdateTypes": [
|
||||||
"major"
|
"major"
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { isDeepStrictEqual } from 'node:util';
|
|||||||
import type {
|
import type {
|
||||||
TbankInstrumentLookup as MetadataLookupRow,
|
TbankInstrumentLookup as MetadataLookupRow,
|
||||||
TbankInstrument as MetadataRow,
|
TbankInstrument as MetadataRow,
|
||||||
Prisma,
|
|
||||||
} from '../../../generated/prisma/client';
|
} from '../../../generated/prisma/client';
|
||||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
|
||||||
import type { TbankInstrument } from './tbank.client';
|
import type { TbankInstrument } from './tbank.client';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { toJson } from '../../prisma/to-json';
|
||||||
|
import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service';
|
||||||
import { TbankErrorCode } from './tbank-error-code';
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
|
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
const FIRST_BATCH_OFFSET = 0;
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
@@ -119,11 +120,8 @@ export class InstrumentMetadataRepository {
|
|||||||
active: ReadonlySet<string>,
|
active: ReadonlySet<string>,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const valid = await tx.tbankConnection.updateMany({
|
const valid = await lockConnectionGeneration(tx, attemptOwner);
|
||||||
where: { ...attemptOwner, invalidToken: false },
|
if (!valid) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!valid.count) return null;
|
|
||||||
const scope = {
|
const scope = {
|
||||||
...attemptOwner,
|
...attemptOwner,
|
||||||
scopeKey: `metadata:${metadataKey(identity)}`,
|
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||||
@@ -131,11 +129,7 @@ export class InstrumentMetadataRepository {
|
|||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, runId, active)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === runId &&
|
|
||||||
active.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -171,15 +165,11 @@ export class InstrumentMetadataRepository {
|
|||||||
|
|
||||||
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: 'complete',
|
||||||
...attempt,
|
progress: {},
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', progress: {} },
|
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
const aliases = new Map(
|
const aliases = new Map(
|
||||||
results.map((result) => [`${result.type}:${result.id}`, result]),
|
results.map((result) => [`${result.type}:${result.id}`, result]),
|
||||||
);
|
);
|
||||||
@@ -216,9 +206,7 @@ export class InstrumentMetadataRepository {
|
|||||||
const previous = await tx.tbankInstrument.findUnique({
|
const previous = await tx.tbankInstrument.findUnique({
|
||||||
where: { provider_uid: key },
|
where: { provider_uid: key },
|
||||||
});
|
});
|
||||||
const payload = JSON.parse(
|
const payload = toJson(instrument);
|
||||||
JSON.stringify(instrument),
|
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
const data = {
|
const data = {
|
||||||
metadata: payload,
|
metadata: payload,
|
||||||
figi:
|
figi:
|
||||||
@@ -268,18 +256,17 @@ export class InstrumentMetadataRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
await this.prisma.tbankSyncWork.updateMany({
|
await claimSyncWork(
|
||||||
where: {
|
this.prisma,
|
||||||
...attempt,
|
attempt,
|
||||||
state: 'running',
|
{
|
||||||
...(success ? { connection: { invalidToken: false } } : {}),
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
state: success ? 'complete' : 'failed',
|
state: success ? 'complete' : 'failed',
|
||||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
{ withoutConnectionFence: !success },
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
|
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||||
await this.prisma.tbankSyncWork.updateMany({
|
await this.prisma.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
import { TbankErrorCode } from './tbank-error-code';
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||||
|
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
||||||
@@ -25,6 +26,22 @@ export type TbankSyncAttempt = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Join rule shared by sync reservations: the same process joins its own running attempt
|
||||||
|
// only while that attempt is still active in memory. Set and Map both satisfy `has`.
|
||||||
|
export function isJoinableRunningAttempt<
|
||||||
|
T extends { attemptId: string; processRunId: string; state: string },
|
||||||
|
>(
|
||||||
|
previous: null | T,
|
||||||
|
processRunId: string,
|
||||||
|
active: { has(attemptId: string): boolean },
|
||||||
|
): previous is T {
|
||||||
|
return (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === processRunId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TbankAccessService {
|
export class TbankAccessService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -40,11 +57,8 @@ export class TbankAccessService {
|
|||||||
force: boolean,
|
force: boolean,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, { userId, revision });
|
||||||
where: { userId, revision, invalidToken: false },
|
if (!current) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!current.count) return null;
|
|
||||||
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
@@ -62,11 +76,7 @@ export class TbankAccessService {
|
|||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, processRunId, active)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === processRunId &&
|
|
||||||
active.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -98,15 +108,11 @@ export class TbankAccessService {
|
|||||||
|
|
||||||
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
||||||
if (invalidToken) return this.rejectMembershipToken(attempt);
|
if (invalidToken) return this.rejectMembershipToken(attempt);
|
||||||
const result = await this.prisma.tbankSyncWork.updateMany({
|
const result = await claimSyncWork(this.prisma, attempt, {
|
||||||
where: {
|
state: 'failed',
|
||||||
...attempt,
|
errorCode: TbankErrorCode.Unavailable,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
|
||||||
});
|
});
|
||||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
return result === EXPECTED_UPDATED_CONNECTIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
||||||
@@ -151,15 +157,8 @@ export class TbankAccessService {
|
|||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
// Acquire the write fence before reading membership. Replacement and
|
// Acquire the write fence before reading membership. Replacement and
|
||||||
// attempt supersession cannot interleave with the publication below.
|
// attempt supersession cannot interleave with the publication below.
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
|
||||||
where: {
|
if (!claimed) return false;
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
if (!claimed.count) return false;
|
|
||||||
const { userId, revision } = attempt;
|
const { userId, revision } = attempt;
|
||||||
const previous = await tx.tbankAccount.findMany({
|
const previous = await tx.tbankAccount.findMany({
|
||||||
where: { userId, revision },
|
where: { userId, revision },
|
||||||
@@ -232,11 +231,11 @@ export class TbankAccessService {
|
|||||||
|
|
||||||
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: { ...attempt, state: 'running' },
|
state: 'failed',
|
||||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
errorCode: TbankErrorCode.InvalidToken,
|
||||||
});
|
}, { withoutConnectionFence: true });
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
await tx.tbankConnection.update({
|
await tx.tbankConnection.update({
|
||||||
where: {
|
where: {
|
||||||
userId_revision: {
|
userId_revision: {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Prisma } from '../../../generated/prisma/client';
|
||||||
|
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||||
|
|
||||||
|
const NO_UPDATED_ROWS = 0;
|
||||||
|
|
||||||
|
// Shared owners for the tbank-sync DB claim/lock protocol.
|
||||||
|
// Both helpers are CAS fences: they run inside the caller's transaction and
|
||||||
|
// keep the exact where/data shapes they replaced. Callers keep deciding
|
||||||
|
// success from the claim count exactly as before.
|
||||||
|
|
||||||
|
// Claims the active TbankSyncWork row: only the still-running attempt of a
|
||||||
|
// valid connection may transition. `data` is the state transition to apply.
|
||||||
|
export type ClaimOptions = {
|
||||||
|
// Overrides the default 'running' fence (e.g. an initiator row also
|
||||||
|
// accepts 'complete').
|
||||||
|
state: Prisma.TbankSyncWorkWhereInput['state'] | undefined;
|
||||||
|
// true drops the `connection: { invalidToken: false }` guard for fences
|
||||||
|
// that must also fire on an invalidated connection.
|
||||||
|
withoutConnectionFence: boolean | undefined;
|
||||||
|
};
|
||||||
|
export async function claimSyncWork(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
data: Prisma.TbankSyncWorkUpdateManyMutationInput,
|
||||||
|
options: Partial<ClaimOptions> = {},
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: options.state ?? 'running',
|
||||||
|
...(options.withoutConnectionFence
|
||||||
|
? {}
|
||||||
|
: { connection: { invalidToken: false } }),
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locks the connection generation before reserving or publishing work: the
|
||||||
|
// row must still be the current revision with a valid token. `extra`
|
||||||
|
// (e.g. account membership) is merged into the where clause.
|
||||||
|
export async function lockConnectionGeneration(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
owner: { revision: string; userId: string },
|
||||||
|
extra: Prisma.TbankConnectionWhereInput = {},
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await tx.tbankConnection.updateMany({
|
||||||
|
where: { ...owner, ...extra, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
return result.count > NO_UPDATED_ROWS;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export { InstrumentMetadataService } from './instrument-metadata.service';
|
|||||||
export { metadataKey } from './instrument-metadata.service';
|
export { metadataKey } from './instrument-metadata.service';
|
||||||
export type { MetadataIdentity } from './instrument-metadata.service';
|
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||||
export {
|
export {
|
||||||
|
isJoinableRunningAttempt,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||||
|
|
||||||
jest.mock('bcrypt', () => ({
|
jest.mock('bcrypt', () => ({
|
||||||
hash: jest.fn(() => Promise.resolve('password-hash')),
|
hash: jest.fn(() => Promise.resolve('password-hash')),
|
||||||
compare: jest.fn(() => Promise.resolve(true)),
|
compare: jest.fn(() => Promise.resolve(true)),
|
||||||
@@ -51,7 +53,7 @@ describe('AuthService', () => {
|
|||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const service = new AuthService(prisma as never, jwt);
|
const service = new AuthService(new AuthRepository(prisma as never), jwt);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|||||||
@@ -7,15 +7,13 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
||||||
import { Session, User } from '../../../../generated/prisma/client';
|
import type { Session, User } from '../../../../generated/prisma/client';
|
||||||
|
|
||||||
import { HttpErrorCode } from '../../../common/error-code';
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
|
||||||
import { AuthErrorCode } from './auth-error-code';
|
import { AuthErrorCode } from './auth-error-code';
|
||||||
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
|
||||||
|
|
||||||
const PASSWORD_HASH_ROUNDS = 12;
|
const PASSWORD_HASH_ROUNDS = 12;
|
||||||
export const REFRESH_TTL_MS = 604_800_000;
|
export const REFRESH_TTL_MS = 604_800_000;
|
||||||
|
|
||||||
@@ -28,7 +26,7 @@ const DUMMY_PASSWORD_HASH =
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly repository: AuthRepository,
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,27 +34,18 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.repository.findUserByEmail(email);
|
||||||
where: { email },
|
|
||||||
});
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: User;
|
const user = await this.repository.createUser({
|
||||||
try {
|
id: randomUUID(),
|
||||||
user = await this.prisma.user.create({
|
email,
|
||||||
data: {
|
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||||
id: randomUUID(),
|
});
|
||||||
email,
|
if (!user) {
|
||||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (isUniqueConstraintError(error)) {
|
|
||||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
}
|
}
|
||||||
@@ -65,9 +54,7 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.repository.findUserByEmail(email);
|
||||||
where: { email },
|
|
||||||
});
|
|
||||||
const valid = await bcrypt.compare(
|
const valid = await bcrypt.compare(
|
||||||
password,
|
password,
|
||||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
@@ -92,10 +79,7 @@ export class AuthService {
|
|||||||
if (payload.type !== 'refresh') {
|
if (payload.type !== 'refresh') {
|
||||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.repository.findSessionWithUser(payload.sid);
|
||||||
where: { id: payload.sid },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
!session ||
|
!session ||
|
||||||
session.userId !== payload.sub ||
|
session.userId !== payload.sub ||
|
||||||
@@ -103,7 +87,7 @@ export class AuthService {
|
|||||||
session.expiresAt <= new Date()
|
session.expiresAt <= new Date()
|
||||||
) {
|
) {
|
||||||
if (session && !session.revokedAt) {
|
if (session && !session.revokedAt) {
|
||||||
await this.prisma.session.delete({ where: { id: session.id } });
|
await this.repository.deleteSession(session.id);
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
@@ -124,18 +108,13 @@ export class AuthService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.type !== 'refresh') return;
|
if (payload.type !== 'refresh') return;
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.repository.findSession(payload.sid);
|
||||||
where: { id: payload.sid },
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
session &&
|
session &&
|
||||||
!session.revokedAt &&
|
!session.revokedAt &&
|
||||||
session.refreshTokenHash === this.hash(token)
|
session.refreshTokenHash === this.hash(token)
|
||||||
) {
|
) {
|
||||||
await this.prisma.session.update({
|
await this.repository.revokeSession(session.id);
|
||||||
where: { id: session.id },
|
|
||||||
data: { revokedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +123,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async userById(id: string): Promise<User> {
|
async userById(id: string): Promise<User> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
const user = await this.repository.findUserById(id);
|
||||||
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -152,14 +131,7 @@ export class AuthService {
|
|||||||
private async createSession(
|
private async createSession(
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
const session = await this.prisma.session.create({
|
const session = await this.repository.createSession(user.id, randomUUID());
|
||||||
data: {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId: user.id,
|
|
||||||
refreshTokenHash: '',
|
|
||||||
expiresAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return this.rotateSession({ ...session, user });
|
return this.rotateSession({ ...session, user });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,16 +152,8 @@ export class AuthService {
|
|||||||
refreshTokenHash: this.hash(refreshToken),
|
refreshTokenHash: this.hash(refreshToken),
|
||||||
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
||||||
};
|
};
|
||||||
if (expectedRefreshTokenHash) {
|
if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) {
|
||||||
const updated = await this.prisma.session.updateMany({
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
|
||||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
user: this.toUser(session.user),
|
user: this.toUser(session.user),
|
||||||
@@ -205,12 +169,3 @@ export class AuthService {
|
|||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUniqueConstraintError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof error === 'object' &&
|
|
||||||
error !== null &&
|
|
||||||
'code' in error &&
|
|
||||||
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
|
|||||||
|
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
import { AuthService } from './application/auth.service';
|
import { AuthService } from './application/auth.service';
|
||||||
|
import { AuthRepository } from './infrastructure/auth.repository';
|
||||||
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||||
import { AuthController } from './presentation/auth.controller';
|
import { AuthController } from './presentation/auth.controller';
|
||||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||||
@@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
|||||||
],
|
],
|
||||||
exports: [JwtAuthGuard],
|
exports: [JwtAuthGuard],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
|
||||||
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
findUserByEmail(email: string) {
|
||||||
|
return this.prisma.user.findUnique({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: { email: string; id: string; passwordHash: string }) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.user.create({ data });
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueConstraintError(error)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserById(id: string) {
|
||||||
|
return this.prisma.user.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
findSessionWithUser(id: string) {
|
||||||
|
return this.prisma.session.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findSession(id: string) {
|
||||||
|
return this.prisma.session.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSession(id: string) {
|
||||||
|
return this.prisma.session.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeSession(id: string) {
|
||||||
|
return this.prisma.session.update({
|
||||||
|
where: { id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(userId: string, id: string) {
|
||||||
|
return this.prisma.session.create({
|
||||||
|
data: { id, userId, refreshTokenHash: '', expiresAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateSession(
|
||||||
|
id: string,
|
||||||
|
data: { expiresAt: Date; refreshTokenHash: string },
|
||||||
|
expectedRefreshTokenHash?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (expectedRefreshTokenHash) {
|
||||||
|
const updated = await this.prisma.session.updateMany({
|
||||||
|
where: { id, refreshTokenHash: expectedRefreshTokenHash },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return updated.count === EXPECTED_ROTATED_SESSIONS;
|
||||||
|
}
|
||||||
|
await this.prisma.session.update({ where: { id }, data });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueConstraintError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
import type { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
||||||
import { bankBalance } from '../domain/bank-card.calculations';
|
import { bankBalance } from '../domain/bank-card.calculations';
|
||||||
import { rubMoney } from '../domain/rub-money';
|
import { rubMoney } from '../domain/rub-money';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
@@ -20,24 +20,13 @@ import {
|
|||||||
missing,
|
missing,
|
||||||
} from './bank-card-input';
|
} from './bank-card-input';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_CARDS = 1;
|
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankCardsService {
|
export class BankCardsService {
|
||||||
constructor(
|
constructor(private readonly repository: BankCardsRepository) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly repository: BankCardsRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
||||||
const rows = await tx.bankCardOperation.findMany({
|
const rows = await this.repository.cardOperations(tx, card);
|
||||||
where: {
|
|
||||||
userId: card.userId,
|
|
||||||
deletedAt: null,
|
|
||||||
OR: [{ cardId: card.id }, { targetCardId: card.id }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
id: card.id,
|
id: card.id,
|
||||||
name: card.name,
|
name: card.name,
|
||||||
@@ -53,20 +42,15 @@ export class BankCardsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list(userId: string, includeArchived = false) {
|
list(userId: string, includeArchived = false) {
|
||||||
return this.prisma.$transaction(async (tx) =>
|
return this.repository.read(async (tx) =>
|
||||||
Promise.all(
|
Promise.all((await this.repository.cards(tx, userId, includeArchived)).map(
|
||||||
(
|
(card) => this.cardView(tx, card),
|
||||||
await tx.bankCard.findMany({
|
)),
|
||||||
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
|
|
||||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
|
||||||
})
|
|
||||||
).map((card) => this.cardView(tx, card)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(userId: string, id: string) {
|
get(userId: string, id: string) {
|
||||||
return this.prisma.$transaction(async (tx) =>
|
return this.repository.read(async (tx) =>
|
||||||
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,7 +59,7 @@ export class BankCardsService {
|
|||||||
const input = cardInput(dto),
|
const input = cardInput(dto),
|
||||||
hash = fingerprint(input);
|
hash = fingerprint(input);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
const existing = await this.repository.cardById(tx, dto.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.userId !== userId) missing();
|
if (existing.userId !== userId) missing();
|
||||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
||||||
@@ -88,8 +72,11 @@ export class BankCardsService {
|
|||||||
await this.repository.seed(tx, userId);
|
await this.repository.seed(tx, userId);
|
||||||
return this.cardView(
|
return this.cardView(
|
||||||
tx,
|
tx,
|
||||||
await tx.bankCard.create({
|
await this.repository.createCard(tx, {
|
||||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
...input,
|
||||||
|
id: dto.id,
|
||||||
|
userId,
|
||||||
|
createFingerprint: hash,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -122,20 +109,16 @@ export class BankCardsService {
|
|||||||
}
|
}
|
||||||
currentDate(input.openingDate);
|
currentDate(input.openingDate);
|
||||||
if (
|
if (
|
||||||
await tx.bankCardOperation.findFirst({
|
await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate)
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
deletedAt: null,
|
|
||||||
date: { lt: input.openingDate },
|
|
||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
) {
|
) {
|
||||||
fail(BankErrorCode.OpeningDateConflict);
|
fail(BankErrorCode.OpeningDateConflict);
|
||||||
}
|
}
|
||||||
const changed = await tx.bankCard.updateMany({
|
const updated = await this.repository.updateCard(
|
||||||
where: { id, userId, version: dto.version },
|
tx,
|
||||||
data: {
|
userId,
|
||||||
|
id,
|
||||||
|
dto.version,
|
||||||
|
{
|
||||||
...input,
|
...input,
|
||||||
paymentSystem: input.paymentSystem ?? null,
|
paymentSystem: input.paymentSystem ?? null,
|
||||||
version: { increment: 1 },
|
version: { increment: 1 },
|
||||||
@@ -147,9 +130,8 @@ export class BankCardsService {
|
|||||||
: null,
|
: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
|
return this.cardView(tx, updated);
|
||||||
return this.cardView(tx, await this.repository.card(tx, userId, id));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { GoneException, Injectable } from '@nestjs/common';
|
import { GoneException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
BankCardOperation,
|
BankCardOperation,
|
||||||
Prisma,
|
Prisma,
|
||||||
} from '../../../../../generated/prisma/client';
|
} from '../../../../../generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
||||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
|
import {
|
||||||
|
operationFieldConflict,
|
||||||
|
operationKindChanged,
|
||||||
|
} from '../domain/bank-operation.rules';
|
||||||
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
import {
|
import {
|
||||||
@@ -28,117 +32,22 @@ import {
|
|||||||
periodBounds,
|
periodBounds,
|
||||||
} from './bank-card-input';
|
} from './bank-card-input';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_ROWS = 1;
|
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankOperationsService {
|
export class BankOperationsService {
|
||||||
constructor(
|
constructor(private readonly repository: BankCardsRepository) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly repository: BankCardsRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private async operationView(
|
private async operationView(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
row: BankCardOperation,
|
row: BankCardOperation,
|
||||||
): Promise<BankOperationDto> {
|
): Promise<BankOperationDto> {
|
||||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
return (await this.pageViews(tx, [row]))[0];
|
||||||
const target = row.targetCardId
|
|
||||||
? await this.repository.card(tx, row.userId, row.targetCardId)
|
|
||||||
: null;
|
|
||||||
const source = row.originalExpenseId
|
|
||||||
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
|
||||||
: null;
|
|
||||||
const categoryId = source?.categoryId ?? row.categoryId;
|
|
||||||
const category = categoryId
|
|
||||||
? await tx.expenseCategory.findFirst({
|
|
||||||
where: { id: categoryId, userId: row.userId },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const refunds =
|
|
||||||
row.kind === BankOperationKind.Expense || source
|
|
||||||
? await tx.bankCardOperation.findMany({
|
|
||||||
where: {
|
|
||||||
userId: row.userId,
|
|
||||||
originalExpenseId: source?.id ?? row.id,
|
|
||||||
deletedAt: null,
|
|
||||||
...(source ? { id: { not: row.id } } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
kind: row.kind as BankOperationDto['kind'],
|
|
||||||
cardId: row.cardId,
|
|
||||||
cardName: card.name,
|
|
||||||
targetCardId: row.targetCardId,
|
|
||||||
targetCardName: target?.name ?? null,
|
|
||||||
categoryId,
|
|
||||||
categoryName: category?.name ?? null,
|
|
||||||
originalExpenseId: row.originalExpenseId,
|
|
||||||
amount: rubMoney(row.amountKopecks),
|
|
||||||
availableRefund:
|
|
||||||
row.kind === BankOperationKind.Expense || source
|
|
||||||
? rubMoney(
|
|
||||||
(source?.amountKopecks ?? row.amountKopecks) -
|
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
date: row.date,
|
|
||||||
description: row.description,
|
|
||||||
version: row.version,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
||||||
const userId = rows[0]?.userId;
|
const userId = rows[0]?.userId;
|
||||||
if (!userId) return [];
|
if (!userId) return [];
|
||||||
const cardIds = new Set(
|
const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows);
|
||||||
rows.flatMap((row) =>
|
|
||||||
[row.cardId, row.targetCardId].filter(
|
|
||||||
(id): id is string => id !== null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const sourceIds = new Set(
|
|
||||||
rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])),
|
|
||||||
);
|
|
||||||
const [cards, sources] = await Promise.all([
|
|
||||||
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
|
|
||||||
tx.bankCardOperation.findMany({
|
|
||||||
where: { userId, id: { in: [...sourceIds] } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const categoryIds = new Set(
|
|
||||||
rows
|
|
||||||
.map(
|
|
||||||
(row) =>
|
|
||||||
sources.find((source) => source.id === row.originalExpenseId)
|
|
||||||
?.categoryId ?? row.categoryId,
|
|
||||||
)
|
|
||||||
.filter((id): id is string => id !== null),
|
|
||||||
);
|
|
||||||
const expenseIds = new Set(
|
|
||||||
rows.flatMap((row) => {
|
|
||||||
const source = sources.find((item) => item.id === row.originalExpenseId);
|
|
||||||
return row.kind === BankOperationKind.Expense || source
|
|
||||||
? [source?.id ?? row.id]
|
|
||||||
: [];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const [categories, refunds] = await Promise.all([
|
|
||||||
tx.expenseCategory.findMany({
|
|
||||||
where: { userId, id: { in: [...categoryIds] } },
|
|
||||||
}),
|
|
||||||
tx.bankCardOperation.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
originalExpenseId: { in: [...expenseIds] },
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
||||||
const sourcesById = new Map(sources.map((source) => [source.id, source]));
|
const sourcesById = new Map(sources.map((source) => [source.id, source]));
|
||||||
const categoriesById = new Map(categories.map((category) => [category.id, category]));
|
const categoriesById = new Map(categories.map((category) => [category.id, category]));
|
||||||
@@ -207,49 +116,28 @@ export class BankOperationsService {
|
|||||||
fail(BankErrorCode.InvalidDate);
|
fail(BankErrorCode.InvalidDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (old && operationKindChanged(old.kind, input.kind,
|
||||||
old &&
|
old.originalExpenseId, input.originalExpenseId)) {
|
||||||
(old.kind !== input.kind ||
|
|
||||||
old.originalExpenseId !== input.originalExpenseId)
|
|
||||||
) {
|
|
||||||
fail(BankErrorCode.ImmutableOperation);
|
fail(BankErrorCode.ImmutableOperation);
|
||||||
}
|
}
|
||||||
if (input.kind === BankOperationKind.Transfer) {
|
if (operationFieldConflict(input)) fail();
|
||||||
if (
|
|
||||||
!input.targetCardId ||
|
|
||||||
input.targetCardId === input.cardId ||
|
|
||||||
input.categoryId ||
|
|
||||||
input.originalExpenseId
|
|
||||||
) {
|
|
||||||
fail();
|
|
||||||
}
|
|
||||||
} else if (input.targetCardId) fail();
|
|
||||||
if (input.kind === BankOperationKind.Expense) {
|
if (input.kind === BankOperationKind.Expense) {
|
||||||
if (input.originalExpenseId) fail();
|
|
||||||
if (!input.categoryId) {
|
if (!input.categoryId) {
|
||||||
const category = await tx.expenseCategory.findUnique({
|
const category = await this.repository.defaultCategory(tx, userId);
|
||||||
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
|
||||||
});
|
|
||||||
if (!category) missing();
|
if (!category) missing();
|
||||||
input.categoryId = category.id;
|
input.categoryId = category.id;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!(await tx.expenseCategory.findFirst({
|
!(await this.repository.category(tx, userId, input.categoryId))
|
||||||
where: { userId, id: input.categoryId },
|
|
||||||
}))
|
|
||||||
) {
|
) {
|
||||||
missing();
|
missing();
|
||||||
}
|
}
|
||||||
} else if (input.categoryId) fail();
|
|
||||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
|
|
||||||
fail();
|
|
||||||
}
|
}
|
||||||
if (input.kind === BankOperationKind.Refund) {
|
if (input.kind === BankOperationKind.Refund) {
|
||||||
if (!input.originalExpenseId) fail();
|
|
||||||
const source = await this.repository.operation(
|
const source = await this.repository.operation(
|
||||||
tx,
|
tx,
|
||||||
userId,
|
userId,
|
||||||
input.originalExpenseId,
|
input.originalExpenseId as string,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
source.deletedAt ||
|
source.deletedAt ||
|
||||||
@@ -258,29 +146,11 @@ export class BankOperationsService {
|
|||||||
) {
|
) {
|
||||||
fail(BankErrorCode.InvalidRefund);
|
fail(BankErrorCode.InvalidRefund);
|
||||||
}
|
}
|
||||||
if (
|
await this.repository.updateOperation(tx,
|
||||||
(
|
{ id: source.id, userId, version: source.version, deletedAt: null },
|
||||||
await tx.bankCardOperation.updateMany({
|
{ version: { increment: 1 } },
|
||||||
where: {
|
);
|
||||||
id: source.id,
|
const refunds = await this.repository.refunds(tx, userId, source.id, old?.id);
|
||||||
userId,
|
|
||||||
version: source.version,
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
data: { version: { increment: 1 } },
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
|
||||||
const refunds = await tx.bankCardOperation.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
originalExpenseId: source.id,
|
|
||||||
deletedAt: null,
|
|
||||||
...(old ? { id: { not: old.id } } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
||||||
if (
|
if (
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
||||||
@@ -291,9 +161,7 @@ export class BankOperationsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (old?.kind === BankOperationKind.Expense) {
|
if (old?.kind === BankOperationKind.Expense) {
|
||||||
const refunds = await tx.bankCardOperation.findMany({
|
const refunds = await this.repository.refunds(tx, userId, old.id);
|
||||||
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
refunds.length &&
|
refunds.length &&
|
||||||
(input.cardId !== old.cardId ||
|
(input.cardId !== old.cardId ||
|
||||||
@@ -310,9 +178,7 @@ export class BankOperationsService {
|
|||||||
const normalized = operationInput(dto),
|
const normalized = operationInput(dto),
|
||||||
hash = fingerprint(normalized);
|
hash = fingerprint(normalized);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const existing = await tx.bankCardOperation.findUnique({
|
const existing = await this.repository.operationById(tx, dto.id);
|
||||||
where: { id: dto.id },
|
|
||||||
});
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.userId !== userId) missing();
|
if (existing.userId !== userId) missing();
|
||||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
||||||
@@ -328,15 +194,15 @@ export class BankOperationsService {
|
|||||||
await this.validateOperation(tx, userId, input);
|
await this.validateOperation(tx, userId, input);
|
||||||
return this.operationView(
|
return this.operationView(
|
||||||
tx,
|
tx,
|
||||||
await tx.bankCardOperation.create({
|
await this.repository.createOperation(tx, {
|
||||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
...input, id: dto.id, userId, createFingerprint: hash,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getOperation(userId: string, id: string) {
|
getOperation(userId: string, id: string) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.repository.read(async (tx) => {
|
||||||
const row = await this.repository.operation(tx, userId, id);
|
const row = await this.repository.operation(tx, userId, id);
|
||||||
if (row.deletedAt) missing();
|
if (row.deletedAt) missing();
|
||||||
return this.operationView(tx, row);
|
return this.operationView(tx, row);
|
||||||
@@ -361,16 +227,10 @@ export class BankOperationsService {
|
|||||||
description: dto.description ?? old.description ?? undefined,
|
description: dto.description ?? old.description ?? undefined,
|
||||||
});
|
});
|
||||||
await this.validateOperation(tx, userId, input, old);
|
await this.validateOperation(tx, userId, input, old);
|
||||||
if (
|
await this.repository.updateOperation(tx,
|
||||||
(
|
{ id, userId, version: dto.version, deletedAt: null },
|
||||||
await tx.bankCardOperation.updateMany({
|
{ ...input, version: { increment: 1 } },
|
||||||
where: { id, userId, version: dto.version, deletedAt: null },
|
);
|
||||||
data: { ...input, version: { increment: 1 } },
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
|
||||||
return this.operationView(
|
return this.operationView(
|
||||||
tx,
|
tx,
|
||||||
await this.repository.operation(tx, userId, id),
|
await this.repository.operation(tx, userId, id),
|
||||||
@@ -389,9 +249,7 @@ export class BankOperationsService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
row.kind === BankOperationKind.Expense &&
|
row.kind === BankOperationKind.Expense &&
|
||||||
(await tx.bankCardOperation.findFirst({
|
(await this.repository.dependentRefund(tx, userId, id))
|
||||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
|
||||||
}))
|
|
||||||
) {
|
) {
|
||||||
conflict(BankErrorCode.DependentRefunds);
|
conflict(BankErrorCode.DependentRefunds);
|
||||||
}
|
}
|
||||||
@@ -401,27 +259,15 @@ export class BankOperationsService {
|
|||||||
userId,
|
userId,
|
||||||
row.originalExpenseId,
|
row.originalExpenseId,
|
||||||
);
|
);
|
||||||
if (
|
await this.repository.updateOperation(tx,
|
||||||
(
|
{ id: source.id, userId, version: source.version },
|
||||||
await tx.bankCardOperation.updateMany({
|
{ version: { increment: 1 } },
|
||||||
where: { id: source.id, userId, version: source.version },
|
);
|
||||||
data: { version: { increment: 1 } },
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(
|
|
||||||
await tx.bankCardOperation.updateMany({
|
|
||||||
where: { id, userId, version, deletedAt: null },
|
|
||||||
data: { deletedAt: new Date(), version: { increment: 1 } },
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
}
|
||||||
|
await this.repository.updateOperation(tx,
|
||||||
|
{ id, userId, version, deletedAt: null },
|
||||||
|
{ deletedAt: new Date(), version: { increment: 1 } },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,25 +280,13 @@ export class BankOperationsService {
|
|||||||
const period = expenses
|
const period = expenses
|
||||||
? undefined
|
? undefined
|
||||||
: periodBounds(query as BankOperationsQueryDto);
|
: periodBounds(query as BankOperationsQueryDto);
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.repository.read(async (tx) => {
|
||||||
await this.repository.card(tx, userId, id);
|
await this.repository.card(tx, userId, id);
|
||||||
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
||||||
page = query.page ?? FIRST_PAGE;
|
page = query.page ?? FIRST_PAGE;
|
||||||
const where: Prisma.BankCardOperationWhereInput = {
|
const { total, rows } = await this.repository.operationPage(
|
||||||
userId,
|
tx, userId, id, period, limit, page,
|
||||||
deletedAt: null,
|
);
|
||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
|
||||||
...(period
|
|
||||||
? { date: { gte: period.from, lte: period.to } }
|
|
||||||
: { kind: BankOperationKind.Expense }),
|
|
||||||
};
|
|
||||||
const total = await tx.bankCardOperation.count({ where });
|
|
||||||
const rows = await tx.bankCardOperation.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
|
||||||
skip: (page - FIRST_PAGE) * limit,
|
|
||||||
take: limit,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
items: await this.pageViews(tx, rows),
|
items: await this.pageViews(tx, rows),
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -1,81 +1,24 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
||||||
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
import { conflict, missing, name } from './bank-card-input';
|
import { name } from './bank-card-input';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_CATEGORIES = 1;
|
|
||||||
const VERSION_INCREMENT = 1;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpenseCategoriesService {
|
export class ExpenseCategoriesService {
|
||||||
constructor(
|
constructor(private readonly repository: BankCardsRepository) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly repository: BankCardsRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async categories(userId: string) {
|
async categories(userId: string) {
|
||||||
return (
|
return this.repository.categories(userId);
|
||||||
await this.prisma.expenseCategory.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
|
||||||
})
|
|
||||||
).map(({ id, name, seedKey, version }) => ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
isDefault: seedKey === 'other',
|
|
||||||
version,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createCategory(userId: string, value: string) {
|
createCategory(userId: string, value: string) {
|
||||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.createCategory(userId, label);
|
||||||
await this.repository.seed(tx, userId);
|
|
||||||
const row = await tx.expenseCategory.create({
|
|
||||||
data: {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId,
|
|
||||||
name: label,
|
|
||||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
isDefault: false,
|
|
||||||
version: row.version,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCategory(userId: string, id: string, value: string, version: number) {
|
updateCategory(userId: string, id: string, value: string, version: number) {
|
||||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.updateCategory(userId, id, label, version);
|
||||||
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
|
|
||||||
if (!row) missing();
|
|
||||||
if (
|
|
||||||
(
|
|
||||||
await tx.expenseCategory.updateMany({
|
|
||||||
where: { id, userId, version },
|
|
||||||
data: {
|
|
||||||
name: label,
|
|
||||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
|
||||||
version: { increment: 1 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_CATEGORIES
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: label,
|
|
||||||
isDefault: row.seedKey === 'other',
|
|
||||||
version: version + VERSION_INCREMENT,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
|
import {
|
||||||
|
operationFieldConflict,
|
||||||
|
OperationFieldRule,
|
||||||
|
operationKindChanged,
|
||||||
|
} from './bank-operation.rules';
|
||||||
|
|
||||||
|
const cardId = 'card-1';
|
||||||
|
|
||||||
|
function fields(
|
||||||
|
overrides: Partial<Parameters<typeof operationFieldConflict>[0]> = {},
|
||||||
|
) {
|
||||||
|
return { kind: BankOperationKind.Topup, cardId, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('operationFieldConflict', () => {
|
||||||
|
it('allows a valid topup', () => {
|
||||||
|
expect(operationFieldConflict(fields())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a topup with a purchase link', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({ originalExpenseId: 'op-1' }),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-transfer with a target card', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(fields({ targetCardId: 'card-2' })),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expense', () => {
|
||||||
|
it('allows the default category to be resolved by the service', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({ kind: BankOperationKind.Expense }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a purchase link', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Expense,
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
originalExpenseId: 'op-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a plain categorized expense', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({ kind: BankOperationKind.Expense, categoryId: 'cat-1' }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transfer', () => {
|
||||||
|
it('requires a target card', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(fields({ kind: BankOperationKind.Transfer })),
|
||||||
|
).toBe(OperationFieldRule.MissingTargetCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a transfer to the same card', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Transfer,
|
||||||
|
targetCardId: cardId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.SameTargetCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a transfer with a category or purchase link', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Transfer,
|
||||||
|
targetCardId: 'card-2',
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Transfer,
|
||||||
|
targetCardId: 'card-2',
|
||||||
|
originalExpenseId: 'op-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refund', () => {
|
||||||
|
it('requires a purchase link', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(fields({ kind: BankOperationKind.Refund })),
|
||||||
|
).toBe(OperationFieldRule.MissingOriginalExpense);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a category', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Refund,
|
||||||
|
originalExpenseId: 'op-1',
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a linked refund', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({ kind: BankOperationKind.Refund, originalExpenseId: 'op-1' }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operationKindChanged', () => {
|
||||||
|
it('flags kind and purchase link changes', () => {
|
||||||
|
expect(operationKindChanged('expense', 'expense', 'op-1', 'op-1')).toBe(false);
|
||||||
|
expect(operationKindChanged('expense', 'refund', null, null)).toBe(true);
|
||||||
|
expect(operationKindChanged('refund', 'refund', 'op-1', null)).toBe(true);
|
||||||
|
expect(operationKindChanged('refund', 'refund', null, 'op-1')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
|
|
||||||
|
export type OperationFields = {
|
||||||
|
cardId: string;
|
||||||
|
categoryId?: null | string;
|
||||||
|
kind: BankOperationKind;
|
||||||
|
originalExpenseId?: null | string;
|
||||||
|
targetCardId?: null | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum OperationFieldRule {
|
||||||
|
InvalidCombination = 'invalidCombination',
|
||||||
|
MissingOriginalExpense = 'missingOriginalExpense',
|
||||||
|
MissingTargetCard = 'missingTargetCard',
|
||||||
|
SameTargetCard = 'sameTargetCard',
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferConflict(fields: OperationFields): null | OperationFieldRule {
|
||||||
|
if (!fields.targetCardId) {
|
||||||
|
return OperationFieldRule.MissingTargetCard;
|
||||||
|
}
|
||||||
|
if (fields.targetCardId === fields.cardId) {
|
||||||
|
return OperationFieldRule.SameTargetCard;
|
||||||
|
}
|
||||||
|
if (fields.categoryId || fields.originalExpenseId) {
|
||||||
|
return OperationFieldRule.InvalidCombination;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expenseConflict(fields: OperationFields): null | OperationFieldRule {
|
||||||
|
if (fields.originalExpenseId) {
|
||||||
|
return OperationFieldRule.InvalidCombination;
|
||||||
|
}
|
||||||
|
// Expense without a category uses the user's default category in the service.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refundConflict(fields: OperationFields): null | OperationFieldRule {
|
||||||
|
return fields.originalExpenseId
|
||||||
|
? null
|
||||||
|
: OperationFieldRule.MissingOriginalExpense;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure kind/field compatibility rules for bank operations; no Prisma or Nest.
|
||||||
|
* Returns the first violated rule, or null when the combination is allowed.
|
||||||
|
*/
|
||||||
|
export function operationFieldConflict(
|
||||||
|
fields: OperationFields,
|
||||||
|
): null | OperationFieldRule {
|
||||||
|
if (fields.kind === BankOperationKind.Transfer) {
|
||||||
|
return transferConflict(fields);
|
||||||
|
}
|
||||||
|
if (fields.targetCardId) return OperationFieldRule.InvalidCombination;
|
||||||
|
if (fields.kind === BankOperationKind.Expense) {
|
||||||
|
return expenseConflict(fields);
|
||||||
|
}
|
||||||
|
if (fields.categoryId) return OperationFieldRule.InvalidCombination;
|
||||||
|
if (fields.kind === BankOperationKind.Refund) {
|
||||||
|
return refundConflict(fields);
|
||||||
|
}
|
||||||
|
// Topup: no target, no category, no purchase link.
|
||||||
|
return fields.originalExpenseId
|
||||||
|
? OperationFieldRule.InvalidCombination
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationKindChanged(
|
||||||
|
oldKind: string,
|
||||||
|
newKind: string,
|
||||||
|
oldOriginalExpenseId: null | string,
|
||||||
|
newOriginalExpenseId: null | string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
oldKind !== newKind ||
|
||||||
|
oldOriginalExpenseId !== newOriginalExpenseId
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { Prisma } from '../../../../../generated/prisma/client';
|
import { BankCard, BankCardOperation, Prisma } from '../../../../../generated/prisma/client';
|
||||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
@@ -9,10 +9,15 @@ import {
|
|||||||
conflict,
|
conflict,
|
||||||
missing,
|
missing,
|
||||||
} from '../application/bank-card-input';
|
} from '../application/bank-card-input';
|
||||||
|
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
|
|
||||||
const INITIAL_RETRY_COUNT = 0;
|
const INITIAL_RETRY_COUNT = 0;
|
||||||
const LAST_SEED_OFFSET = 1;
|
const LAST_SEED_OFFSET = 1;
|
||||||
|
const EXPECTED_UPDATED_CARDS = 1;
|
||||||
|
const EXPECTED_UPDATED_OPERATIONS = 1;
|
||||||
|
const EXPECTED_UPDATED_CATEGORIES = 1;
|
||||||
|
const VERSION_INCREMENT = 1;
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
||||||
@@ -32,6 +37,220 @@ const seeds = [
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankCardsRepository {
|
export class BankCardsRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
read<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
return this.prisma.$transaction(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
cards(tx: Tx, userId: string, includeArchived = false) {
|
||||||
|
return tx.bankCard.findMany({
|
||||||
|
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
|
||||||
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cardOperations(tx: Tx, card: BankCard) {
|
||||||
|
return tx.bankCardOperation.findMany({
|
||||||
|
where: {
|
||||||
|
userId: card.userId,
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [{ cardId: card.id }, { targetCardId: card.id }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
operationById(tx: Tx, id: string) {
|
||||||
|
return tx.bankCardOperation.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createOperation(tx: Tx, data: Prisma.BankCardOperationUncheckedCreateInput) {
|
||||||
|
return tx.bankCardOperation.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOperation(
|
||||||
|
tx: Tx,
|
||||||
|
where: Prisma.BankCardOperationWhereInput,
|
||||||
|
data: Prisma.BankCardOperationUpdateManyMutationInput,
|
||||||
|
) {
|
||||||
|
const result = await tx.bankCardOperation.updateMany({ where, data });
|
||||||
|
if (result.count !== EXPECTED_UPDATED_OPERATIONS) conflict();
|
||||||
|
}
|
||||||
|
|
||||||
|
category(tx: Tx, userId: string, id: string) {
|
||||||
|
return tx.expenseCategory.findFirst({ where: { id, userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultCategory(tx: Tx, userId: string) {
|
||||||
|
return tx.expenseCategory.findUnique({
|
||||||
|
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refunds(tx: Tx, userId: string, originalExpenseId: string, excludeId?: string) {
|
||||||
|
return tx.bankCardOperation.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
originalExpenseId,
|
||||||
|
deletedAt: null,
|
||||||
|
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dependentRefund(tx: Tx, userId: string, originalExpenseId: string) {
|
||||||
|
return tx.bankCardOperation.findFirst({
|
||||||
|
where: { userId, originalExpenseId, deletedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async operationViewData(tx: Tx, userId: string, rows: BankCardOperation[]) {
|
||||||
|
const cardIds = new Set(rows.flatMap((row) =>
|
||||||
|
[row.cardId, row.targetCardId].filter((id): id is string => id !== null),
|
||||||
|
));
|
||||||
|
const sourceIds = new Set(rows.flatMap((row) =>
|
||||||
|
row.originalExpenseId ? [row.originalExpenseId] : [],
|
||||||
|
));
|
||||||
|
const [cards, sources] = await Promise.all([
|
||||||
|
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
|
||||||
|
tx.bankCardOperation.findMany({ where: { userId, id: { in: [...sourceIds] } } }),
|
||||||
|
]);
|
||||||
|
const categoryIds = new Set(rows.map((row) =>
|
||||||
|
sources.find((source) => source.id === row.originalExpenseId)?.categoryId ?? row.categoryId,
|
||||||
|
).filter((id): id is string => id !== null));
|
||||||
|
const expenseIds = new Set(rows.flatMap((row) => {
|
||||||
|
const source = sources.find((item) => item.id === row.originalExpenseId);
|
||||||
|
return row.kind === BankOperationKind.Expense || source ? [source?.id ?? row.id] : [];
|
||||||
|
}));
|
||||||
|
const [categories, refunds] = await Promise.all([
|
||||||
|
tx.expenseCategory.findMany({ where: { userId, id: { in: [...categoryIds] } } }),
|
||||||
|
tx.bankCardOperation.findMany({
|
||||||
|
where: { userId, originalExpenseId: { in: [...expenseIds] }, deletedAt: null },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { cards, sources, categories, refunds };
|
||||||
|
}
|
||||||
|
|
||||||
|
async operationPage(
|
||||||
|
tx: Tx,
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
period: undefined | { from: string; to: string },
|
||||||
|
limit: number,
|
||||||
|
page: number,
|
||||||
|
) {
|
||||||
|
const where: Prisma.BankCardOperationWhereInput = {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
|
...(period
|
||||||
|
? { date: { gte: period.from, lte: period.to } }
|
||||||
|
: { kind: BankOperationKind.Expense }),
|
||||||
|
};
|
||||||
|
const total = await tx.bankCardOperation.count({ where });
|
||||||
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
skip: (page - FIRST_PAGE) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return { total, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
cardById(tx: Tx, id: string) {
|
||||||
|
return tx.bankCard.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createCard(tx: Tx, data: Prisma.BankCardUncheckedCreateInput) {
|
||||||
|
return tx.bankCard.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCard(
|
||||||
|
tx: Tx,
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
version: number,
|
||||||
|
data: Prisma.BankCardUpdateManyMutationInput,
|
||||||
|
) {
|
||||||
|
const changed = await tx.bankCard.updateMany({
|
||||||
|
where: { id, userId, version },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
|
||||||
|
return this.card(tx, userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasOperationsBefore(tx: Tx, userId: string, id: string, openingDate: string) {
|
||||||
|
return tx.bankCardOperation.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
date: { lt: openingDate },
|
||||||
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async categories(userId: string) {
|
||||||
|
return (
|
||||||
|
await this.prisma.expenseCategory.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||||
|
})
|
||||||
|
).map(({ id, name, seedKey, version }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
isDefault: seedKey === 'other',
|
||||||
|
version,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
createCategory(userId: string, label: string) {
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
await this.seed(tx, userId);
|
||||||
|
const row = await tx.expenseCategory.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
name: label,
|
||||||
|
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
isDefault: false,
|
||||||
|
version: row.version,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCategory(userId: string, id: string, label: string, version: number) {
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
|
||||||
|
if (!row) missing();
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await tx.expenseCategory.updateMany({
|
||||||
|
where: { id, userId, version },
|
||||||
|
data: {
|
||||||
|
name: label,
|
||||||
|
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).count !== EXPECTED_UPDATED_CATEGORIES
|
||||||
|
) {
|
||||||
|
conflict();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: label,
|
||||||
|
isDefault: row.seedKey === 'other',
|
||||||
|
version: version + VERSION_INCREMENT,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
|
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ApiProperty,
|
ApiProperty,
|
||||||
ApiPropertyOptional,
|
ApiPropertyOptional,
|
||||||
|
getSchemaPath,
|
||||||
IntersectionType,
|
IntersectionType,
|
||||||
OmitType,
|
OmitType,
|
||||||
PartialType,
|
PartialType,
|
||||||
@@ -153,6 +154,49 @@ export class CreateBankOperationDto {
|
|||||||
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
|
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAPI-only variants. Runtime validation stays on CreateBankOperationDto so
|
||||||
|
// invalid combinations keep the existing BANK_INVALID_INPUT response.
|
||||||
|
class BankOperationCreateBaseDto extends OmitType(CreateBankOperationDto, [
|
||||||
|
'kind', 'targetCardId', 'categoryId', 'originalExpenseId',
|
||||||
|
] as const) {}
|
||||||
|
|
||||||
|
export class CreateExpenseOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Expense] }) declare kind: 'expense';
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' }) declare categoryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateTopupOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Topup] }) declare kind: 'topup';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateTransferOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Transfer] }) declare kind: 'transfer';
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare targetCardId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateRefundOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Refund] }) declare kind: 'refund';
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare originalExpenseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createBankOperationSchema = {
|
||||||
|
oneOf: [
|
||||||
|
CreateExpenseOperationDto,
|
||||||
|
CreateTopupOperationDto,
|
||||||
|
CreateTransferOperationDto,
|
||||||
|
CreateRefundOperationDto,
|
||||||
|
].map((model) => ({ $ref: getSchemaPath(model) })),
|
||||||
|
discriminator: {
|
||||||
|
propertyName: 'kind',
|
||||||
|
mapping: {
|
||||||
|
expense: getSchemaPath(CreateExpenseOperationDto),
|
||||||
|
topup: getSchemaPath(CreateTopupOperationDto),
|
||||||
|
transfer: getSchemaPath(CreateTransferOperationDto),
|
||||||
|
refund: getSchemaPath(CreateRefundOperationDto),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
export class UpdateBankOperationDto extends PartialType(
|
export class UpdateBankOperationDto extends PartialType(
|
||||||
OmitType(CreateBankOperationDto, ['id'] as const),
|
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
|
ApiExtraModels,
|
||||||
ApiNoContentResponse,
|
ApiNoContentResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
@@ -46,6 +48,11 @@ import {
|
|||||||
CategoryNameDto,
|
CategoryNameDto,
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
CreateBankOperationDto,
|
CreateBankOperationDto,
|
||||||
|
createBankOperationSchema,
|
||||||
|
CreateExpenseOperationDto,
|
||||||
|
CreateRefundOperationDto,
|
||||||
|
CreateTopupOperationDto,
|
||||||
|
CreateTransferOperationDto,
|
||||||
ExpenseCategoryDto,
|
ExpenseCategoryDto,
|
||||||
UpdateBankCardDto,
|
UpdateBankCardDto,
|
||||||
UpdateBankOperationDto,
|
UpdateBankOperationDto,
|
||||||
@@ -73,6 +80,12 @@ type AuthRequest = Request & { user: AccessPayload };
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiTags('bank-cards')
|
@ApiTags('bank-cards')
|
||||||
|
@ApiExtraModels(
|
||||||
|
CreateExpenseOperationDto,
|
||||||
|
CreateTopupOperationDto,
|
||||||
|
CreateTransferOperationDto,
|
||||||
|
CreateRefundOperationDto,
|
||||||
|
)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiProblemResponses()
|
@ApiProblemResponses()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -131,6 +144,7 @@ export class BankCardsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('operations')
|
@Post('operations')
|
||||||
|
@ApiBody({ schema: createBankOperationSchema })
|
||||||
@ApiCreatedResponse({ type: BankOperationDto })
|
@ApiCreatedResponse({ type: BankOperationDto })
|
||||||
createOperation(
|
createOperation(
|
||||||
@Req() req: AuthRequest,
|
@Req() req: AuthRequest,
|
||||||
|
|||||||
@@ -1,43 +1,25 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
|
||||||
decimalFromString,
|
|
||||||
divide,
|
|
||||||
} from '../../../../common/decimal';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import {
|
|
||||||
MILLISECONDS_PER_DAY,
|
|
||||||
MILLISECONDS_PER_SECOND,
|
|
||||||
} from '../../../../common/time';
|
|
||||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
|
||||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
|
||||||
type TbankCandle,
|
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankCoupon,
|
TbankLastPrice,
|
||||||
type TbankDividend,
|
|
||||||
type TbankFundamentals,
|
|
||||||
type TbankLastPrice,
|
|
||||||
type TbankShare,
|
|
||||||
tbankSourceNeedsRefresh,
|
tbankSourceNeedsRefresh,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
|
||||||
import {
|
import {
|
||||||
CatalogDiscoveryRepository,
|
CatalogDiscoveryRepository,
|
||||||
catalogScope,
|
catalogScope,
|
||||||
type CatalogSource,
|
type CatalogSource,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
import { type CatalogPayload, CatalogSourceLoader } from './catalog-source.loader';
|
||||||
|
export type { CatalogPayload } from './catalog-source.loader';
|
||||||
import { catalogSearchLimit } from './catalog-selection';
|
import { catalogSearchLimit } from './catalog-selection';
|
||||||
import { previousClose, timestamp } from './instrument-market';
|
|
||||||
import { publicInstrument } from './public-instrument';
|
import { publicInstrument } from './public-instrument';
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
@@ -93,9 +75,7 @@ export class CatalogEnrichmentService {
|
|||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
private readonly moex: MoexClient,
|
private readonly loader: CatalogSourceLoader,
|
||||||
private readonly ratings: NcrRatingsClient,
|
|
||||||
private readonly candles: CandleRepository,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async readMany(
|
async readMany(
|
||||||
@@ -110,6 +90,7 @@ export class CatalogEnrichmentService {
|
|||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
to: string;
|
||||||
},
|
},
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const { revision } = await this.access.local(userId);
|
const { revision } = await this.access.local(userId);
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
@@ -123,6 +104,7 @@ export class CatalogEnrichmentService {
|
|||||||
false,
|
false,
|
||||||
kinds,
|
kinds,
|
||||||
range,
|
range,
|
||||||
|
refreshBefore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const work = inputs.length
|
const work = inputs.length
|
||||||
@@ -175,6 +157,7 @@ export class CatalogEnrichmentService {
|
|||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
to: string;
|
||||||
},
|
},
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
const reservation = this.reservations.then(async () => {
|
const reservation = this.reservations.then(async () => {
|
||||||
@@ -189,7 +172,9 @@ export class CatalogEnrichmentService {
|
|||||||
);
|
);
|
||||||
const policy = sourcePolicy(input.kind);
|
const policy = sourcePolicy(input.kind);
|
||||||
const needsRefresh = force || !row?.fetchedAt ||
|
const needsRefresh = force || !row?.fetchedAt ||
|
||||||
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
|
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date())) ||
|
||||||
|
// A walk reads a saved calendar once: later accounts see the new fetchedAt.
|
||||||
|
(policy === 'calendar' && refreshBefore !== undefined && row.fetchedAt < refreshBefore);
|
||||||
if (!needsRefresh) {
|
if (!needsRefresh) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -332,169 +317,6 @@ export class CatalogEnrichmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
|
|
||||||
private async load(
|
|
||||||
input: CatalogSource,
|
|
||||||
token: string,
|
|
||||||
attempt: TbankSyncAttempt,
|
|
||||||
): Promise<CatalogPayload> {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
|
||||||
if (instrument === undefined) throw new Error('Missing catalog response');
|
|
||||||
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : 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(toSavedCandle);
|
|
||||||
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 &&
|
|
||||||
!['bond-events', 'coupons', 'dividends'].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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
|
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
|
||||||
private async drain() {
|
private async drain() {
|
||||||
try {
|
try {
|
||||||
@@ -532,7 +354,7 @@ export class CatalogEnrichmentService {
|
|||||||
batch.map((item) => item.input.uid),
|
batch.map((item) => item.input.uid),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
payload = await this.load(producer.input, token, producer.attempt);
|
payload = await this.loader.load(producer.input, token, producer.attempt);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed = true;
|
failed = true;
|
||||||
@@ -634,24 +456,6 @@ function sourceInputs(
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const previousCloseLookbackDays = 20;
|
|
||||||
const previousCloseLookbackMs =
|
|
||||||
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
|
||||||
const payoutHistoryYears = 5;
|
|
||||||
const payoutFutureYears = 2;
|
|
||||||
export type CatalogPayload = {
|
|
||||||
bondEvents?: MoexBondEvent[];
|
|
||||||
candle?: null | TbankCandle;
|
|
||||||
coupons?: TbankCoupon[];
|
|
||||||
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
|
||||||
description?: null | string;
|
|
||||||
dividends?: TbankDividend[];
|
|
||||||
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
|
||||||
fundamentals?: null | TbankFundamentals;
|
|
||||||
instrument?: null | TbankBond | TbankShare;
|
|
||||||
price?: null | TbankLastPrice;
|
|
||||||
tradingStatus?: null | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function sourcePolicy(kind: CatalogSource['kind']) {
|
function sourcePolicy(kind: CatalogSource['kind']) {
|
||||||
if (
|
if (
|
||||||
@@ -665,6 +469,7 @@ function sourcePolicy(kind: CatalogSource['kind']) {
|
|||||||
) {
|
) {
|
||||||
return 'quote';
|
return 'quote';
|
||||||
}
|
}
|
||||||
|
if (['bond-events', 'dividends'].includes(kind)) return 'calendar';
|
||||||
return 'stable';
|
return 'stable';
|
||||||
}
|
}
|
||||||
function producerKey(input: CatalogSource) {
|
function producerKey(input: CatalogSource) {
|
||||||
@@ -673,6 +478,3 @@ function producerKey(input: CatalogSource) {
|
|||||||
? `bond-metadata:${input.uid}`
|
? `bond-metadata:${input.uid}`
|
||||||
: catalogScope(input);
|
: catalogScope(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotationScale = 9;
|
|
||||||
const quotationUnits = 1_000_000_000n;
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decimalFromString,
|
||||||
|
divide,
|
||||||
|
} from '../../../../common/decimal';
|
||||||
|
import {
|
||||||
|
MILLISECONDS_PER_DAY,
|
||||||
|
MILLISECONDS_PER_SECOND,
|
||||||
|
} from '../../../../common/time';
|
||||||
|
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||||
|
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||||
|
import {
|
||||||
|
type TbankBond,
|
||||||
|
type TbankCandle,
|
||||||
|
TbankClient,
|
||||||
|
type TbankCoupon,
|
||||||
|
type TbankDividend,
|
||||||
|
type TbankFundamentals,
|
||||||
|
type TbankLastPrice,
|
||||||
|
type TbankShare,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||||
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import {
|
||||||
|
CatalogDiscoveryRepository,
|
||||||
|
type CatalogSource,
|
||||||
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
|
import { publicInstrument } from './public-instrument';
|
||||||
|
|
||||||
|
export type CatalogPayload = {
|
||||||
|
bondEvents?: MoexBondEvent[];
|
||||||
|
candle?: null | TbankCandle;
|
||||||
|
coupons?: TbankCoupon[];
|
||||||
|
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
||||||
|
description?: null | string;
|
||||||
|
dividends?: TbankDividend[];
|
||||||
|
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
||||||
|
fundamentals?: null | TbankFundamentals;
|
||||||
|
instrument?: null | TbankBond | TbankShare;
|
||||||
|
price?: null | TbankLastPrice;
|
||||||
|
tradingStatus?: null | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousCloseLookbackDays = 20;
|
||||||
|
const previousCloseLookbackMs =
|
||||||
|
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
||||||
|
const payoutHistoryYears = 5;
|
||||||
|
const payoutFutureYears = 2;
|
||||||
|
const quotationScale = 9;
|
||||||
|
const quotationUnits = 1_000_000_000n;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogSourceLoader {
|
||||||
|
constructor(
|
||||||
|
private readonly repository: CatalogDiscoveryRepository,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly moex: MoexClient,
|
||||||
|
private readonly ratings: NcrRatingsClient,
|
||||||
|
private readonly candles: CandleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
|
||||||
|
async load(
|
||||||
|
input: CatalogSource,
|
||||||
|
token: string,
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
): Promise<CatalogPayload> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
||||||
|
if (instrument === undefined) throw new Error('Missing catalog response');
|
||||||
|
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : 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(toSavedCandle);
|
||||||
|
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 &&
|
||||||
|
!['bond-events', 'coupons', 'dividends'].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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,17 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
|
import {
|
||||||
|
claimSyncWork,
|
||||||
|
lockConnectionGeneration,
|
||||||
|
} from '../../../../integrations/tbank/tbank-sync-claim';
|
||||||
|
import {
|
||||||
|
isJoinableRunningAttempt,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import { toJson } from '../../../../prisma/to-json';
|
||||||
import {
|
import {
|
||||||
type CandleRange,
|
type CandleRange,
|
||||||
confirmedCandleCoverage,
|
confirmedCandleCoverage,
|
||||||
@@ -62,34 +70,30 @@ export class CandleRepository {
|
|||||||
priority = 1,
|
priority = 1,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, owner);
|
||||||
where: { ...owner, invalidToken: false },
|
if (!current) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!current.count) return null;
|
|
||||||
const scope = { ...owner, scopeKey };
|
const scope = { ...owner, scopeKey };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
const joinable = isJoinableRunningAttempt(previous, runId, active)
|
||||||
previous?.state === 'running' &&
|
? previous
|
||||||
previous.processRunId === runId &&
|
: null;
|
||||||
active.has(previous.attemptId)
|
if (joinable) {
|
||||||
) {
|
const previousIntent = joinable.intent as Prisma.JsonObject;
|
||||||
const previousIntent = previous.intent as Prisma.JsonObject;
|
|
||||||
const previousNow =
|
const previousNow =
|
||||||
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
||||||
const nextPriority = Math.min(previous.priority, priority);
|
const nextPriority = Math.min(joinable.priority, priority);
|
||||||
if (
|
if (
|
||||||
nextPriority === previous.priority &&
|
nextPriority === joinable.priority &&
|
||||||
(!intent.force ||
|
(!intent.force ||
|
||||||
(previousIntent.force === true &&
|
(previousIntent.force === true &&
|
||||||
(!intent.now || intent.now <= previousNow)))
|
(!intent.now || intent.now <= previousNow)))
|
||||||
) {
|
) {
|
||||||
return { work: previous, launch: false };
|
return { work: joinable, launch: false };
|
||||||
}
|
}
|
||||||
const work = await tx.tbankSyncWork.update({
|
const work = await tx.tbankSyncWork.update({
|
||||||
where: { id: previous.id },
|
where: { id: joinable.id },
|
||||||
data: {
|
data: {
|
||||||
priority: nextPriority,
|
priority: nextPriority,
|
||||||
intent: {
|
intent: {
|
||||||
@@ -115,7 +119,7 @@ export class CandleRepository {
|
|||||||
processRunId: runId,
|
processRunId: runId,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
priority,
|
priority,
|
||||||
intent: JSON.parse(JSON.stringify(intent)) as Prisma.InputJsonObject,
|
intent: toJson(intent),
|
||||||
progress: {},
|
progress: {},
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
};
|
};
|
||||||
@@ -163,24 +167,17 @@ export class CandleRepository {
|
|||||||
).values(),
|
).values(),
|
||||||
];
|
];
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const valid = await tx.tbankSyncWork.updateMany({
|
const valid = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
progress: { lastWindow: key },
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { progress: { lastWindow: key } },
|
|
||||||
});
|
});
|
||||||
if (!valid.count) return false;
|
if (!valid) return false;
|
||||||
const identity = {
|
const identity = {
|
||||||
workId: attempt.id,
|
workId: attempt.id,
|
||||||
attemptId: attempt.attemptId,
|
attemptId: attempt.attemptId,
|
||||||
sourceKey: 'candles',
|
sourceKey: 'candles',
|
||||||
itemKey: key,
|
itemKey: key,
|
||||||
};
|
};
|
||||||
const payload = JSON.parse(
|
const payload = toJson({ range, candles: rows, fetchedAt });
|
||||||
JSON.stringify({ range, candles: rows, fetchedAt }),
|
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
await tx.tbankSyncStage.upsert({
|
await tx.tbankSyncStage.upsert({
|
||||||
where: { workId_attemptId_sourceKey_itemKey: identity },
|
where: { workId_attemptId_sourceKey_itemKey: identity },
|
||||||
create: { ...identity, payload },
|
create: { ...identity, payload },
|
||||||
@@ -196,18 +193,11 @@ export class CandleRepository {
|
|||||||
completeWork = true,
|
completeWork = true,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
...(completeWork ? { state: 'complete' } : {}),
|
||||||
...attempt,
|
errorCode: null,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
...(completeWork ? { state: 'complete' } : {}),
|
|
||||||
errorCode: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
const staged = await tx.tbankSyncStage.findMany({
|
const staged = await tx.tbankSyncStage.findMany({
|
||||||
where: {
|
where: {
|
||||||
workId: attempt.id,
|
workId: attempt.id,
|
||||||
@@ -311,28 +301,17 @@ export class CandleRepository {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!origin) return false;
|
if (!origin) return false;
|
||||||
const result = await tx.tbankSyncWork.updateMany({
|
const result = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: 'complete',
|
||||||
...attempt,
|
errorCode: null,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', errorCode: null },
|
|
||||||
});
|
});
|
||||||
return result.count > NO_UPDATED_ROWS;
|
return result > NO_UPDATED_ROWS;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await tx.tbankSyncWork.updateMany({
|
await claimSyncWork(tx, attempt, { state: 'failed', errorCode });
|
||||||
where: {
|
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'failed', errorCode },
|
|
||||||
});
|
|
||||||
await tx.tbankSyncStage.deleteMany({
|
await tx.tbankSyncStage.deleteMany({
|
||||||
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||||
});
|
});
|
||||||
|
|||||||
+44
-73
@@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
|
||||||
import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
|
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim';
|
||||||
|
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import { toJson } from '../../../../prisma/to-json';
|
||||||
|
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
const REVISION_INCREMENT = 1;
|
const REVISION_INCREMENT = 1;
|
||||||
@@ -159,27 +159,19 @@ export class CatalogDiscoveryRepository {
|
|||||||
initiator: TbankSyncAttempt = attempt,
|
initiator: TbankSyncAttempt = attempt,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const origin = await tx.tbankSyncWork.updateMany({
|
const origin = await claimSyncWork(
|
||||||
where: {
|
tx,
|
||||||
...initiator,
|
initiator,
|
||||||
state: { in: ['running', 'complete'] },
|
{ progress: {} },
|
||||||
connection: { invalidToken: false },
|
{ state: { in: ['running', 'complete'] } },
|
||||||
},
|
);
|
||||||
data: { progress: {} },
|
if (!origin) return false;
|
||||||
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
|
state: 'complete',
|
||||||
|
progress: {},
|
||||||
});
|
});
|
||||||
if (!origin.count) return false;
|
if (!claimed) return false;
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const payload = toJson(value);
|
||||||
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 };
|
const identity = { provider, uid: input.uid };
|
||||||
await tx.tbankInstrument.upsert({
|
await tx.tbankInstrument.upsert({
|
||||||
where: { provider_uid: identity },
|
where: { provider_uid: identity },
|
||||||
@@ -202,12 +194,10 @@ export class CatalogDiscoveryRepository {
|
|||||||
const previous = await tx.tbankInstrument.findUniqueOrThrow({
|
const previous = await tx.tbankInstrument.findUniqueOrThrow({
|
||||||
where: { provider_uid: identity },
|
where: { provider_uid: identity },
|
||||||
});
|
});
|
||||||
const metadata = JSON.parse(
|
const metadata = toJson({
|
||||||
JSON.stringify({
|
...instrument,
|
||||||
...instrument,
|
instrumentType: input.instrumentType,
|
||||||
instrumentType: input.instrumentType,
|
});
|
||||||
}),
|
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
await tx.tbankInstrument.update({
|
await tx.tbankInstrument.update({
|
||||||
where: { provider_uid: identity },
|
where: { provider_uid: identity },
|
||||||
data: {
|
data: {
|
||||||
@@ -284,20 +274,13 @@ export class CatalogDiscoveryRepository {
|
|||||||
active: ReadonlySet<string>,
|
active: ReadonlySet<string>,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, owner);
|
||||||
where: { ...owner, invalidToken: false },
|
if (!current) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!current.count) return null;
|
|
||||||
const scope = { ...owner, scopeKey: catalogScope(input) };
|
const scope = { ...owner, scopeKey: catalogScope(input) };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, runId, active)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === runId &&
|
|
||||||
active.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -345,34 +328,28 @@ export class CatalogDiscoveryRepository {
|
|||||||
now: Date,
|
now: Date,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: 'complete',
|
||||||
...attempt,
|
progress: {},
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', progress: {} },
|
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
for (const instrument of instruments) {
|
for (const instrument of instruments) {
|
||||||
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
||||||
const identity = { provider, uid: instrument.uid };
|
const identity = { provider, uid: instrument.uid };
|
||||||
const payload = JSON.parse(
|
const payload = toJson({
|
||||||
JSON.stringify({
|
uid: instrument.uid,
|
||||||
uid: instrument.uid,
|
figi: instrument.figi,
|
||||||
figi: instrument.figi,
|
ticker: instrument.ticker,
|
||||||
ticker: instrument.ticker,
|
name: instrument.name,
|
||||||
name: instrument.name,
|
isin: instrument.isin,
|
||||||
isin: instrument.isin,
|
classCode: instrument.classCode,
|
||||||
classCode: instrument.classCode,
|
instrumentKind: instrument.instrumentKind,
|
||||||
instrumentKind: instrument.instrumentKind,
|
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
||||||
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
currency: instrument.currency,
|
||||||
currency: instrument.currency,
|
brand: instrument.brand
|
||||||
brand: instrument.brand
|
? { logoName: instrument.brand.logoName }
|
||||||
? { logoName: instrument.brand.logoName }
|
: undefined,
|
||||||
: undefined,
|
});
|
||||||
}),
|
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
await tx.tbankInstrument.upsert({
|
await tx.tbankInstrument.upsert({
|
||||||
where: { provider_uid: identity },
|
where: { provider_uid: identity },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -465,6 +442,7 @@ export class CatalogDiscoveryRepository {
|
|||||||
|
|
||||||
finish(attempt: TbankSyncAttempt, success: boolean) {
|
finish(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||||
await tx.tbankSyncWork.updateMany({
|
await tx.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
@@ -473,16 +451,9 @@ export class CatalogDiscoveryRepository {
|
|||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
await tx.tbankSyncWork.updateMany({
|
await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: success ? 'complete' : 'failed',
|
||||||
...attempt,
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
state: success ? 'complete' : 'failed',
|
|
||||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
|||||||
import { CandleService } from './application/candle.service';
|
import { CandleService } from './application/candle.service';
|
||||||
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||||
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||||
|
import { CatalogSourceLoader } from './application/catalog-source.loader';
|
||||||
import { CandleRepository } from './infrastructure/candle.repository';
|
import { CandleRepository } from './infrastructure/candle.repository';
|
||||||
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
|
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.r
|
|||||||
CandleService,
|
CandleService,
|
||||||
CandleRepository,
|
CandleRepository,
|
||||||
CatalogEnrichmentService,
|
CatalogEnrichmentService,
|
||||||
|
CatalogSourceLoader,
|
||||||
CatalogDiscoveryService,
|
CatalogDiscoveryService,
|
||||||
CatalogDiscoveryRepository,
|
CatalogDiscoveryRepository,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
@@ -21,7 +20,6 @@ import { StockController } from './stocks/presentation/stock.controller';
|
|||||||
controllers: [StockController, BondController],
|
controllers: [StockController, BondController],
|
||||||
exports: [StockIncomeService, BondIncomeService],
|
exports: [StockIncomeService, BondIncomeService],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: 'TBANK_ACCESS', useExisting: TbankAccessService },
|
|
||||||
BondIncomeService,
|
BondIncomeService,
|
||||||
BondMarketService,
|
BondMarketService,
|
||||||
GetStockCompanyUseCase,
|
GetStockCompanyUseCase,
|
||||||
|
|||||||
+5
-1
@@ -1,3 +1,7 @@
|
|||||||
|
jest.mock('../../../../../integrations/tbank/tbank.api', () => ({
|
||||||
|
TbankAccessService: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||||
@@ -36,7 +40,7 @@ describe('StockScreenerService', () => {
|
|||||||
client.getAllShares.mockResolvedValue(
|
client.getAllShares.mockResolvedValue(
|
||||||
Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
|
Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
|
||||||
);
|
);
|
||||||
service = new StockScreenerService(access, client as never, logger as never);
|
service = new StockScreenerService(access as never, client as never, logger as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores the full selected catalog and returns stable non-overlapping pages', async () => {
|
it('stores the full selected catalog and returns stable non-overlapping pages', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Inject,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
@@ -10,6 +9,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
|
||||||
|
import { TbankAccessService } from '../../../../../integrations/tbank/tbank.api';
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
||||||
import {
|
import {
|
||||||
selectStockScreenerInstruments,
|
selectStockScreenerInstruments,
|
||||||
@@ -47,17 +47,13 @@ type StockScreenerPage = {
|
|||||||
searchId: string;
|
searchId: string;
|
||||||
};
|
};
|
||||||
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
|
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
|
||||||
type TbankAccess = {
|
|
||||||
local(userId: string): Promise<{ revision: string }>;
|
|
||||||
online<T>(userId: string, run: (connection: { revision: string; token: string }) => Promise<T>): Promise<T>;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StockScreenerService {
|
export class StockScreenerService {
|
||||||
private readonly snapshots = new Map<string, Snapshot>();
|
private readonly snapshots = new Map<string, Snapshot>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('TBANK_ACCESS') private readonly access: TbankAccess,
|
private readonly access: TbankAccessService,
|
||||||
private readonly client: TbankClient,
|
private readonly client: TbankClient,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export class StockController {
|
|||||||
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
|
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
|
||||||
criteria: StockScreenerCriteriaDto,
|
criteria: StockScreenerCriteriaDto,
|
||||||
) {
|
) {
|
||||||
|
// The global whitelist pipe strips unknown keys before this param pipe runs, so only the raw body can reject them.
|
||||||
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
|
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
|
||||||
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
|
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
|
||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { createHash } from 'node:crypto';
|
||||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
@@ -11,51 +10,47 @@ import {
|
|||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { accountHistory, ledgerRange } from '../../investment-sync-plan';
|
import { ledgerRange } from '../../investment-sync-plan';
|
||||||
import {
|
import {
|
||||||
hourlyAttempt,
|
hourlyAttempt,
|
||||||
TbankSyncRepository,
|
TbankSyncRepository,
|
||||||
} from '../../tbank-sync/tbank-sync.api';
|
} from '../../tbank-sync/tbank-sync.api';
|
||||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
import {
|
||||||
|
coverageAsOf,
|
||||||
|
deriveReadCoverage,
|
||||||
|
historyState,
|
||||||
|
serializeRange,
|
||||||
|
} from '../domain/operations-coverage';
|
||||||
import {
|
import {
|
||||||
confirmedOperationRanges,
|
confirmedOperationRanges,
|
||||||
operationIntervalsOverlap,
|
operationIntervalsOverlap,
|
||||||
operationsSyncPriority,
|
|
||||||
planOperationRead,
|
planOperationRead,
|
||||||
uncoveredOperationIntervals,
|
uncoveredOperationIntervals,
|
||||||
} from '../domain/operations-sync-plan';
|
} from '../domain/operations-sync-plan';
|
||||||
import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper';
|
import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper';
|
||||||
import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository';
|
import { BrokerageOperationsReadRepository } from '../infrastructure/brokerage-operations-read.repository';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationsQueryDto,
|
BrokerageOperationsQueryDto,
|
||||||
DEFAULT_OPERATIONS_PAGE_SIZE,
|
DEFAULT_OPERATIONS_PAGE_SIZE,
|
||||||
} from '../presentation/brokerage-operation.dto';
|
} from '../presentation/brokerage-operation.dto';
|
||||||
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
||||||
|
import { LocalOperationsCursorCodec } from './local-operations-cursor.codec';
|
||||||
|
|
||||||
const FIRST_RESULT_OFFSET = 0;
|
const FIRST_RESULT_OFFSET = 0;
|
||||||
const MISSING_MEMBERSHIP_REVISION = 0;
|
const MISSING_MEMBERSHIP_REVISION = 0;
|
||||||
const STALE_FRESHNESS_MS = 0;
|
const STALE_FRESHNESS_MS = 0;
|
||||||
const UNKNOWN_AS_OF_MS = 0;
|
const NO_COVERAGE_ROWS = 0;
|
||||||
const LAST_ROW_INDEX = -1;
|
const LAST_ROW_INDEX = -1;
|
||||||
const UNIX_EPOCH_MS = 0;
|
const UNIX_EPOCH_MS = 0;
|
||||||
|
|
||||||
type LocalOperationsCursor = {
|
|
||||||
accountId: string;
|
|
||||||
at: string;
|
|
||||||
binding: string;
|
|
||||||
id: string;
|
|
||||||
movingEnd?: boolean;
|
|
||||||
to: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerageOperationsService {
|
export class BrokerageOperationsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly metadata: InstrumentMetadataService,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
private readonly repository: BrokerageOperationsRepository,
|
private readonly repository: BrokerageOperationsReadRepository,
|
||||||
private readonly sync: BrokerageOperationsSyncService,
|
private readonly sync: BrokerageOperationsSyncService,
|
||||||
private readonly config: ConfigService,
|
private readonly cursorCodec: LocalOperationsCursorCodec,
|
||||||
private readonly hourlySync: TbankSyncRepository,
|
private readonly hourlySync: TbankSyncRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -65,7 +60,7 @@ export class BrokerageOperationsService {
|
|||||||
query: BrokerageOperationsQueryDto,
|
query: BrokerageOperationsQueryDto,
|
||||||
) {
|
) {
|
||||||
const connection = await this.access.local(userId, accountId);
|
const connection = await this.access.local(userId, accountId);
|
||||||
const cursor = query.cursor ? this.decodeCursor(query.cursor) : undefined;
|
const cursor = query.cursor ? this.cursorCodec.decode(query.cursor) : undefined;
|
||||||
const selected = connection.accounts.filter(
|
const selected = connection.accounts.filter(
|
||||||
(account) => accountId === 'all' || account.accountId === accountId,
|
(account) => accountId === 'all' || account.accountId === accountId,
|
||||||
);
|
);
|
||||||
@@ -124,64 +119,18 @@ export class BrokerageOperationsService {
|
|||||||
const saved = await this.repository.readPage(readInput);
|
const saved = await this.repository.readPage(readInput);
|
||||||
const hourly = await this.hourlySync.hourly(userId);
|
const hourly = await this.hourlySync.hourly(userId);
|
||||||
const accounts = saved?.connection.accounts ?? [];
|
const accounts = saved?.connection.accounts ?? [];
|
||||||
const targets = accounts.flatMap((account) =>
|
const view = deriveReadCoverage({
|
||||||
from < to
|
accounts,
|
||||||
? [
|
connectionMembershipAt: saved?.connection.membershipAt ?? null,
|
||||||
{
|
coverage: saved?.coverage ?? [],
|
||||||
accountId: account.accountId,
|
from,
|
||||||
from,
|
instrumentUid: query.instrumentUid,
|
||||||
to,
|
|
||||||
instrumentUid: query.instrumentUid,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
|
||||||
movingEnd,
|
movingEnd,
|
||||||
});
|
|
||||||
const confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []);
|
|
||||||
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 historyTargets = accountHistory(accounts, now);
|
|
||||||
const history = planOperationRead(
|
|
||||||
historyTargets,
|
|
||||||
saved?.coverage ?? [],
|
|
||||||
now,
|
now,
|
||||||
{ movingEnd: true },
|
to,
|
||||||
);
|
work: saved?.work ?? [],
|
||||||
const knownBoundary = accounts.every(
|
});
|
||||||
(account) => account.openedOn !== null,
|
const { targets, plan, confirmed, missing, available, complete, lastSuccessfulAt, history, knownBoundary, work } = view;
|
||||||
);
|
|
||||||
const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? [];
|
const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? [];
|
||||||
const instruments = await this.metadata.savedMany(
|
const instruments = await this.metadata.savedMany(
|
||||||
rows.map((row) => row.payload as TbankOperationItem),
|
rows.map((row) => row.payload as TbankOperationItem),
|
||||||
@@ -224,49 +173,17 @@ export class BrokerageOperationsService {
|
|||||||
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||||
dependencies: [membershipMeta, ...metadataDependencies],
|
dependencies: [membershipMeta, ...metadataDependencies],
|
||||||
coverage: {
|
coverage: {
|
||||||
requested: targets.map(serialize),
|
requested: targets.map(serializeRange),
|
||||||
completeRanges: confirmed.map(serialize),
|
completeRanges: confirmed.map(serializeRange),
|
||||||
missingRanges: missing.map(serialize),
|
missingRanges: missing.map(serializeRange),
|
||||||
asOf:
|
asOf: coverageAsOf(plan, accounts.map((account) => account.accountId)),
|
||||||
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() ?? UNKNOWN_AS_OF_MS),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toISOString()
|
|
||||||
: null,
|
|
||||||
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
||||||
historyState: saved?.work.some(
|
historyState: historyState(
|
||||||
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
|
knownBoundary,
|
||||||
)
|
history,
|
||||||
? 'loading'
|
saved?.work ?? [],
|
||||||
: saved?.work.some(
|
saved?.coverage.length ?? NO_COVERAGE_ROWS,
|
||||||
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
|
),
|
||||||
)
|
|
||||||
? 'failed'
|
|
||||||
: knownBoundary &&
|
|
||||||
history.intervals.every(
|
|
||||||
(interval) => interval.availability === 'complete',
|
|
||||||
)
|
|
||||||
? 'complete'
|
|
||||||
: saved?.coverage.length
|
|
||||||
? 'partial'
|
|
||||||
: 'missing',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const last = rows.at(LAST_ROW_INDEX);
|
const last = rows.at(LAST_ROW_INDEX);
|
||||||
@@ -301,7 +218,7 @@ export class BrokerageOperationsService {
|
|||||||
hasNext,
|
hasNext,
|
||||||
nextCursor:
|
nextCursor:
|
||||||
hasNext && last && saved
|
hasNext && last && saved
|
||||||
? this.encodeCursor({
|
? this.cursorCodec.encode({
|
||||||
binding: saved.binding,
|
binding: saved.binding,
|
||||||
movingEnd,
|
movingEnd,
|
||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
@@ -316,50 +233,6 @@ export class BrokerageOperationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private encodeCursor(value: LocalOperationsCursor): string {
|
|
||||||
const body = Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
||||||
return `${body}.${createHmac('sha256', this.config.getOrThrow<string>('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<string>('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<LocalOperationsCursor>;
|
|
||||||
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: OperationErrorCode.InvalidOperationsCursor });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async collectRanges(
|
async collectRanges(
|
||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@@ -466,12 +339,12 @@ export class BrokerageOperationsService {
|
|||||||
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||||
dependencies: [membership],
|
dependencies: [membership],
|
||||||
coverage: {
|
coverage: {
|
||||||
requested: targets.map(serialize),
|
requested: targets.map(serializeRange),
|
||||||
completeRanges: confirmed.map(serialize),
|
completeRanges: confirmed.map(serializeRange),
|
||||||
missingRanges: uncoveredOperationIntervals(
|
missingRanges: uncoveredOperationIntervals(
|
||||||
plan.intervals,
|
plan.intervals,
|
||||||
confirmed,
|
confirmed,
|
||||||
).map(serialize),
|
).map(serializeRange),
|
||||||
asOf:
|
asOf:
|
||||||
complete && targets.length
|
complete && targets.length
|
||||||
? new Date(
|
? new Date(
|
||||||
@@ -541,7 +414,3 @@ export class BrokerageOperationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialize(range: { accountId: string; from: Date; to: Date }) {
|
|
||||||
return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { decode, encode } from './local-operations-cursor.codec';
|
||||||
|
|
||||||
|
const secret = 'secret';
|
||||||
|
const cursor = {
|
||||||
|
accountId: 'account',
|
||||||
|
at: '2026-01-01T00:00:00.000Z',
|
||||||
|
binding: 'owner-filter-binding',
|
||||||
|
id: 'operation',
|
||||||
|
movingEnd: false,
|
||||||
|
to: '2026-02-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('local operations cursor', () => {
|
||||||
|
it('roundtrips the opaque cursor, including its owner and filter binding', () => {
|
||||||
|
expect(decode(encode(cursor, secret), secret)).toEqual(cursor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered cursor and a cursor signed with a different key', () => {
|
||||||
|
const encoded = encode(cursor, secret);
|
||||||
|
expect(() => decode(`${encoded}x`, secret)).toThrow();
|
||||||
|
expect(() => decode(encoded, 'different-secret')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||||
|
|
||||||
|
export type LocalOperationsCursor = {
|
||||||
|
accountId: string;
|
||||||
|
at: string;
|
||||||
|
binding: string;
|
||||||
|
id: string;
|
||||||
|
movingEnd?: boolean;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opaque cursor: base64url body + HMAC-SHA256 signature over JWT_SECRET.
|
||||||
|
// Wire format and validation are part of the HTTP contract and must not change.
|
||||||
|
export function encode(value: LocalOperationsCursor, secret: string): string {
|
||||||
|
const body = Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||||
|
return `${body}.${createHmac('sha256', secret).update(body).digest('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decode(value: string, secret: string): LocalOperationsCursor {
|
||||||
|
try {
|
||||||
|
const [body, signature, extra] = value.split('.');
|
||||||
|
const expected = createHmac('sha256', 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<LocalOperationsCursor>;
|
||||||
|
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: OperationErrorCode.InvalidOperationsCursor });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalOperationsCursorCodec {
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
encode(value: LocalOperationsCursor): string {
|
||||||
|
return encode(value, this.secret());
|
||||||
|
}
|
||||||
|
|
||||||
|
decode(value: string): LocalOperationsCursor {
|
||||||
|
return decode(value, this.secret());
|
||||||
|
}
|
||||||
|
|
||||||
|
private secret(): string {
|
||||||
|
return this.config.getOrThrow<string>('JWT_SECRET');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { accountHistory } from '../../investment-sync-plan';
|
||||||
|
import { confirmedOperationRanges, type OperationCoverage,
|
||||||
|
type OperationInterval,
|
||||||
|
operationIntervalsOverlap,
|
||||||
|
operationsSyncPriority,
|
||||||
|
planOperationRead,
|
||||||
|
uncoveredOperationIntervals } from './operations-sync-plan';
|
||||||
|
|
||||||
|
const UNKNOWN_AS_OF_MS = 0;
|
||||||
|
|
||||||
|
type WorkState = {
|
||||||
|
errorCode: null | string;
|
||||||
|
id: string;
|
||||||
|
intervals: OperationInterval[];
|
||||||
|
priority: number;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CoverageAccount = { accountId: string; openedOn: null | string };
|
||||||
|
|
||||||
|
// Pure derivation of the ledger read view: what was requested, what is
|
||||||
|
// confirmed, what is missing, and which sync work is currently relevant.
|
||||||
|
export function deriveReadCoverage(input: {
|
||||||
|
accounts: CoverageAccount[];
|
||||||
|
connectionMembershipAt: Date | null;
|
||||||
|
coverage: OperationCoverage[];
|
||||||
|
from: Date;
|
||||||
|
instrumentUid?: string;
|
||||||
|
movingEnd: boolean;
|
||||||
|
now: Date;
|
||||||
|
to: Date;
|
||||||
|
work: WorkState[];
|
||||||
|
}) {
|
||||||
|
const targets = input.accounts.flatMap((account) =>
|
||||||
|
input.from < input.to
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
accountId: account.accountId,
|
||||||
|
from: input.from,
|
||||||
|
to: input.to,
|
||||||
|
instrumentUid: input.instrumentUid,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const plan = planOperationRead(targets, input.coverage, input.now, {
|
||||||
|
movingEnd: input.movingEnd,
|
||||||
|
});
|
||||||
|
const confirmed = confirmedOperationRanges(plan.intervals, input.coverage);
|
||||||
|
const missing = uncoveredOperationIntervals(
|
||||||
|
plan.intervals,
|
||||||
|
confirmed.map((range) => ({
|
||||||
|
...range,
|
||||||
|
instrumentUid: input.instrumentUid,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const available = Boolean(input.connectionMembershipAt);
|
||||||
|
const complete = available && !missing.length;
|
||||||
|
const relevant = input.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 = input.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 ? input.connectionMembershipAt : null) ??
|
||||||
|
null;
|
||||||
|
const history = planOperationRead(
|
||||||
|
accountHistory(input.accounts, input.now),
|
||||||
|
input.coverage,
|
||||||
|
input.now,
|
||||||
|
{ movingEnd: true },
|
||||||
|
);
|
||||||
|
const knownBoundary = input.accounts.every(
|
||||||
|
(account) => account.openedOn !== null,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
targets,
|
||||||
|
plan,
|
||||||
|
confirmed,
|
||||||
|
missing,
|
||||||
|
available,
|
||||||
|
complete,
|
||||||
|
work,
|
||||||
|
lastSuccessfulAt,
|
||||||
|
history,
|
||||||
|
knownBoundary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverageAsOf(
|
||||||
|
plan: ReturnType<typeof planOperationRead>,
|
||||||
|
accountIds: string[],
|
||||||
|
): null | string {
|
||||||
|
if (
|
||||||
|
accountIds.length &&
|
||||||
|
accountIds.every((accountId) =>
|
||||||
|
plan.intervals.some(
|
||||||
|
(interval) =>
|
||||||
|
interval.accountId === accountId && interval.asOf,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return new Date(
|
||||||
|
Math.min(
|
||||||
|
...accountIds.map((accountId) =>
|
||||||
|
Math.max(
|
||||||
|
...plan.intervals
|
||||||
|
.filter((interval) => interval.accountId === accountId)
|
||||||
|
.map((interval) => interval.asOf?.valueOf() ?? UNKNOWN_AS_OF_MS),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toISOString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function historyState(
|
||||||
|
knownBoundary: boolean,
|
||||||
|
history: ReturnType<typeof planOperationRead>,
|
||||||
|
work: WorkState[],
|
||||||
|
coverageCount: number,
|
||||||
|
): 'complete' | 'failed' | 'loading' | 'missing' | 'partial' {
|
||||||
|
if (
|
||||||
|
work.some(
|
||||||
|
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'loading';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
work.some(
|
||||||
|
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
knownBoundary &&
|
||||||
|
history.intervals.every(
|
||||||
|
(interval) => interval.availability === 'complete',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'complete';
|
||||||
|
}
|
||||||
|
return coverageCount ? 'partial' : 'missing';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRange(range: { accountId: string; from: Date; to: Date }) {
|
||||||
|
return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { type OperationInterval,
|
||||||
|
operationIntervalsOverlap,
|
||||||
|
operationsSyncPriority } from './operations-sync-plan';
|
||||||
|
|
||||||
|
export type OperationsIntent = {
|
||||||
|
atomic: boolean;
|
||||||
|
atomicIntervals?: string[];
|
||||||
|
intervals: string[];
|
||||||
|
screenIntervals?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function intervalKey(interval: OperationInterval): string {
|
||||||
|
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
||||||
|
if (!(interval.from < interval.to)) {
|
||||||
|
throw new Error('Invalid operations interval');
|
||||||
|
}
|
||||||
|
return JSON.stringify([
|
||||||
|
interval.accountId,
|
||||||
|
interval.from.toISOString(),
|
||||||
|
interval.to.toISOString(),
|
||||||
|
interval.instrumentUid ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extendRunningIntent(input: {
|
||||||
|
additions: OperationInterval[];
|
||||||
|
intervals: OperationInterval[];
|
||||||
|
pending: OperationInterval[];
|
||||||
|
previous: OperationsIntent | undefined;
|
||||||
|
previousPriority: number;
|
||||||
|
priority: number;
|
||||||
|
publicationAtomic: boolean;
|
||||||
|
}): OperationsIntent {
|
||||||
|
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
|
||||||
|
const overlapsRequested = (item: OperationInterval) =>
|
||||||
|
intervals.some((target) => operationIntervalsOverlap(item, target));
|
||||||
|
const retainedScreen = previous?.screenIntervals ??
|
||||||
|
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
|
||||||
|
const addedScreen = priority === operationsSyncPriority.screen
|
||||||
|
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
|
||||||
|
: [];
|
||||||
|
const ordered = priority < previousPriority
|
||||||
|
? [
|
||||||
|
...pending.filter(overlapsRequested),
|
||||||
|
...additions,
|
||||||
|
...pending.filter((item) => !overlapsRequested(item)),
|
||||||
|
]
|
||||||
|
: [...pending, ...additions];
|
||||||
|
return {
|
||||||
|
atomic: previous?.atomic ?? publicationAtomic,
|
||||||
|
atomicIntervals: previous?.atomic
|
||||||
|
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
|
||||||
|
: [],
|
||||||
|
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
|
||||||
|
intervals: ordered.map(intervalKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
dateOnlyBounds,
|
||||||
|
DateOnlyRange,
|
||||||
|
moscowDate,
|
||||||
|
} from '../../investment-calendar';
|
||||||
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||||
|
import { decodeInterval } from '../domain/operations-interval-codec';
|
||||||
|
|
||||||
|
const CURSOR_LOOKAHEAD_ROWS = 1;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BrokerageOperationsReadRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async readPage(input: {
|
||||||
|
accountId: string;
|
||||||
|
categories: string[];
|
||||||
|
cursor?: { accountId: string; at: string; binding: string; id: string };
|
||||||
|
filterKey: string;
|
||||||
|
from: Date;
|
||||||
|
instrumentUid?: string;
|
||||||
|
limit?: number;
|
||||||
|
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
|
||||||
|
revision: string;
|
||||||
|
to: Date;
|
||||||
|
userId: 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;
|
||||||
|
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||||
|
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 now = new Date();
|
||||||
|
const bounds = dateOnlyBounds(period, now);
|
||||||
|
return {
|
||||||
|
...bounds,
|
||||||
|
key: `${period.from}/${period.to}`,
|
||||||
|
movingEnd: period.to >= moscowDate(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: OperationErrorCode.InvalidOperationsCursor });
|
||||||
|
}
|
||||||
|
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 + CURSOR_LOOKAHEAD_ROWS }),
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-273
@@ -1,26 +1,24 @@
|
|||||||
import {
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import type {
|
|
||||||
TbankOperationItem,
|
|
||||||
TbankOperationsByCursorResponse,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim';
|
||||||
import {
|
import {
|
||||||
dateOnlyBounds,
|
isJoinableRunningAttempt,
|
||||||
DateOnlyRange,
|
type TbankOperationItem, type TbankOperationsByCursorResponse,
|
||||||
moscowDate,
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
} from '../../investment-calendar';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
import { toJson } from '../../../../prisma/to-json';
|
||||||
|
import {
|
||||||
|
decodeInterval,
|
||||||
|
extendRunningIntent,
|
||||||
|
intervalKey,
|
||||||
|
type OperationsIntent,
|
||||||
|
} from '../domain/operations-interval-codec';
|
||||||
import {
|
import {
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
operationIntervals,
|
operationIntervals,
|
||||||
@@ -33,13 +31,13 @@ import {
|
|||||||
operationTimestamp,
|
operationTimestamp,
|
||||||
} from './brokerage-operation.mapper';
|
} from './brokerage-operation.mapper';
|
||||||
|
|
||||||
const CURSOR_LOOKAHEAD_ROWS = 1;
|
|
||||||
const EMPTY_INTERVAL_COUNT = 0;
|
const EMPTY_INTERVAL_COUNT = 0;
|
||||||
const SINGLE_WORK_GROUP = 1;
|
const SINGLE_WORK_GROUP = 1;
|
||||||
const INITIAL_PAGE_COUNT = 0;
|
const INITIAL_PAGE_COUNT = 0;
|
||||||
const PAGE_INCREMENT = 1;
|
const PAGE_INCREMENT = 1;
|
||||||
const EXPECTED_UPDATED_WORK_ITEMS = 1;
|
const EXPECTED_UPDATED_WORK_ITEMS = 1;
|
||||||
|
|
||||||
|
export type { OperationsIntent } from '../domain/operations-interval-codec';
|
||||||
export type { OperationInterval } from '../domain/operations-sync-plan';
|
export type { OperationInterval } from '../domain/operations-sync-plan';
|
||||||
|
|
||||||
export type OperationAttempt = {
|
export type OperationAttempt = {
|
||||||
@@ -48,12 +46,6 @@ export type OperationAttempt = {
|
|||||||
revision: string;
|
revision: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
type OperationsIntent = {
|
|
||||||
atomic: boolean;
|
|
||||||
atomicIntervals?: string[];
|
|
||||||
intervals: string[];
|
|
||||||
screenIntervals?: string[];
|
|
||||||
};
|
|
||||||
type PageProgress = {
|
type PageProgress = {
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
cursor: string;
|
cursor: string;
|
||||||
@@ -66,176 +58,6 @@ const progressItem = '';
|
|||||||
export class BrokerageOperationsRepository {
|
export class BrokerageOperationsRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async readPage(input: {
|
|
||||||
accountId: string;
|
|
||||||
categories: string[];
|
|
||||||
cursor?: { accountId: string; at: string; binding: string; id: string };
|
|
||||||
filterKey: string;
|
|
||||||
from: Date;
|
|
||||||
instrumentUid?: string;
|
|
||||||
limit?: number;
|
|
||||||
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
|
|
||||||
revision: string;
|
|
||||||
to: Date;
|
|
||||||
userId: 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;
|
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
|
||||||
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 now = new Date();
|
|
||||||
const bounds = dateOnlyBounds(period, now);
|
|
||||||
return {
|
|
||||||
...bounds,
|
|
||||||
key: `${period.from}/${period.to}`,
|
|
||||||
movingEnd: period.to >= moscowDate(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: OperationErrorCode.InvalidOperationsCursor });
|
|
||||||
}
|
|
||||||
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 + CURSOR_LOOKAHEAD_ROWS }),
|
|
||||||
});
|
|
||||||
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[]) {
|
async coverage(userId: string, revision: string, accountIds: string[]) {
|
||||||
const rows = await this.prisma.tbankOperationCoverage.findMany({
|
const rows = await this.prisma.tbankOperationCoverage.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -268,16 +90,10 @@ export class BrokerageOperationsRepository {
|
|||||||
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
|
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const locked = await tx.tbankConnection.updateMany({
|
const locked = await lockConnectionGeneration(tx, { userId, revision }, {
|
||||||
where: {
|
membershipAt: { not: null },
|
||||||
userId,
|
|
||||||
revision,
|
|
||||||
invalidToken: false,
|
|
||||||
membershipAt: { not: null },
|
|
||||||
},
|
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
});
|
||||||
if (!locked.count) return null;
|
if (!locked) return null;
|
||||||
const accounts = await tx.tbankAccount.findMany({
|
const accounts = await tx.tbankAccount.findMany({
|
||||||
where: { userId, revision },
|
where: { userId, revision },
|
||||||
select: { accountId: true, openedOn: true },
|
select: { accountId: true, openedOn: true },
|
||||||
@@ -379,9 +195,7 @@ export class BrokerageOperationsRepository {
|
|||||||
publicationAtomic && previousIntent && !previousIntent.atomic;
|
publicationAtomic && previousIntent && !previousIntent.atomic;
|
||||||
if (
|
if (
|
||||||
touching.length <= SINGLE_WORK_GROUP &&
|
touching.length <= SINGLE_WORK_GROUP &&
|
||||||
previous?.state === 'running' &&
|
isJoinableRunningAttempt(previous, processRunId, runningAttempts) &&
|
||||||
previous.processRunId === processRunId &&
|
|
||||||
runningAttempts.has(previous.attemptId) &&
|
|
||||||
!upgradeGroup
|
!upgradeGroup
|
||||||
) {
|
) {
|
||||||
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
||||||
@@ -656,9 +470,7 @@ export class BrokerageOperationsRepository {
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const payload = JSON.parse(
|
const payload = toJson({ ...operation, cursor: undefined });
|
||||||
JSON.stringify({ ...operation, cursor: undefined }),
|
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
// Provider pagination position is not part of a financial row.
|
// Provider pagination position is not part of a financial row.
|
||||||
const identity = { ...stage, itemKey: operation.id };
|
const identity = { ...stage, itemKey: operation.id };
|
||||||
const previous = await tx.tbankSyncStage.findUnique({
|
const previous = await tx.tbankSyncStage.findUnique({
|
||||||
@@ -882,74 +694,10 @@ export class BrokerageOperationsRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extendRunningIntent(input: {
|
|
||||||
additions: OperationInterval[];
|
|
||||||
intervals: OperationInterval[];
|
|
||||||
pending: OperationInterval[];
|
|
||||||
previous: OperationsIntent | undefined;
|
|
||||||
previousPriority: number;
|
|
||||||
priority: number;
|
|
||||||
publicationAtomic: boolean;
|
|
||||||
}): OperationsIntent {
|
|
||||||
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
|
|
||||||
const overlapsRequested = (item: OperationInterval) =>
|
|
||||||
intervals.some((target) => operationIntervalsOverlap(item, target));
|
|
||||||
const retainedScreen = previous?.screenIntervals ??
|
|
||||||
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
|
|
||||||
const addedScreen = priority === operationsSyncPriority.screen
|
|
||||||
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
|
|
||||||
: [];
|
|
||||||
const ordered = priority < previousPriority
|
|
||||||
? [
|
|
||||||
...pending.filter(overlapsRequested),
|
|
||||||
...additions,
|
|
||||||
...pending.filter((item) => !overlapsRequested(item)),
|
|
||||||
]
|
|
||||||
: [...pending, ...additions];
|
|
||||||
return {
|
|
||||||
atomic: previous?.atomic ?? publicationAtomic,
|
|
||||||
atomicIntervals: previous?.atomic
|
|
||||||
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
|
|
||||||
: [],
|
|
||||||
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
|
|
||||||
intervals: ordered.map(intervalKey),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function intervalKey(interval: OperationInterval): string {
|
|
||||||
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
|
||||||
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(
|
async function claim(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
attempt: OperationAttempt,
|
attempt: OperationAttempt,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const result = await tx.tbankSyncWork.updateMany({
|
const count = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
|
||||||
where: {
|
return count === EXPECTED_UPDATED_WORK_ITEMS;
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
return result.count === EXPECTED_UPDATED_WORK_ITEMS;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { AuthModule } from '../../auth/auth.module';
|
|||||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||||
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||||
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||||
|
import { LocalOperationsCursorCodec } from './application/local-operations-cursor.codec';
|
||||||
|
import { BrokerageOperationsReadRepository } from './infrastructure/brokerage-operations-read.repository';
|
||||||
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
|
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
|
||||||
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ import { BrokerageOperationsController } from './presentation/brokerage-operatio
|
|||||||
providers: [
|
providers: [
|
||||||
BrokerageOperationsService,
|
BrokerageOperationsService,
|
||||||
BrokerageOperationsRepository,
|
BrokerageOperationsRepository,
|
||||||
|
BrokerageOperationsReadRepository,
|
||||||
|
LocalOperationsCursorCodec,
|
||||||
BrokerageOperationsSyncService,
|
BrokerageOperationsSyncService,
|
||||||
],
|
],
|
||||||
exports: [BrokerageOperationsService, BrokerageOperationsSyncService],
|
exports: [BrokerageOperationsService, BrokerageOperationsSyncService],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class ForecastService {
|
|||||||
lastAttempt?: InvestmentResourceMetaDto['lastAttempt'];
|
lastAttempt?: InvestmentResourceMetaDto['lastAttempt'];
|
||||||
membershipRevision: number;
|
membershipRevision: number;
|
||||||
portfolio: InvestmentResourceMetaDto;
|
portfolio: InvestmentResourceMetaDto;
|
||||||
|
replacesLatest?: boolean;
|
||||||
revision: string;
|
revision: string;
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
sources: {
|
sources: {
|
||||||
@@ -74,7 +75,7 @@ export class ForecastService {
|
|||||||
.map((dependency) => dependency.sync)
|
.map((dependency) => dependency.sync)
|
||||||
.find((sync) => sync.state === 'failed');
|
.find((sync) => sync.state === 'failed');
|
||||||
return {
|
return {
|
||||||
data: stored ? filterEvents(stored.payload as T, input.scopeKey) : null,
|
data: stored ? filterEvents(stored.payload as T, input.scopeKey, stored.scopeKey) : null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: input.scopeKey,
|
resourceKey: input.scopeKey,
|
||||||
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
||||||
@@ -95,7 +96,7 @@ export class ForecastService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterEvents<T extends object>(payload: T, scopeKey: string): T {
|
function filterEvents<T extends object>(payload: T, scopeKey: string, savedScopeKey: string): T {
|
||||||
if (!scopeKey.startsWith('events/')) return payload;
|
if (!scopeKey.startsWith('events/')) return payload;
|
||||||
try {
|
try {
|
||||||
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
|
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
|
||||||
@@ -111,8 +112,10 @@ function filterEvents<T extends object>(payload: T, scopeKey: string): T {
|
|||||||
const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
|
const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
|
||||||
totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value)));
|
totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value)));
|
||||||
}
|
}
|
||||||
|
const [, savedFrom, savedTo] = JSON.parse(savedScopeKey.slice('events/'.length)) as [string, string, string];
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
|
window: { from: savedFrom, to: savedTo },
|
||||||
events,
|
events,
|
||||||
incomeTotalsByCurrency: [...totals]
|
incomeTotalsByCurrency: [...totals]
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
|||||||
@@ -218,6 +218,18 @@ describe('persistent portfolio projection', () => {
|
|||||||
expect(snapshot.structure.currencies[0].totalValue).toBe('121.49');
|
expect(snapshot.structure.currencies[0].totalValue).toBe('121.49');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response.
|
||||||
|
it('rejects a stored snapshot payload without the persisted shape', async () => {
|
||||||
|
repository.read.mockResolvedValue({
|
||||||
|
membershipAt,
|
||||||
|
membershipRevision: 1,
|
||||||
|
accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null,
|
||||||
|
portfolio: { payload: { positions: 'broken' }, fetchedAt: membershipAt, dataRevision: 1 } }],
|
||||||
|
work: [],
|
||||||
|
});
|
||||||
|
await expect(service.read('user', 'all')).rejects.toThrow('Corrupted portfolio snapshot payload');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||||
const snapshot: PortfolioDto = {
|
const snapshot: PortfolioDto = {
|
||||||
lastSuccessfulAt: membershipAt.toISOString(),
|
lastSuccessfulAt: membershipAt.toISOString(),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +28,7 @@ import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
|||||||
import {
|
import {
|
||||||
portfolioFreshForMs,
|
portfolioFreshForMs,
|
||||||
PortfolioRepository,
|
PortfolioRepository,
|
||||||
|
portfolioSnapshot,
|
||||||
portfolioWorkKey,
|
portfolioWorkKey,
|
||||||
} from '../infrastructure/portfolio.repository';
|
} from '../infrastructure/portfolio.repository';
|
||||||
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
||||||
@@ -88,18 +91,7 @@ export class PortfolioService {
|
|||||||
type: account.type,
|
type: account.type,
|
||||||
openedOn: account.openedOn,
|
openedOn: account.openedOn,
|
||||||
snapshot: account.portfolio
|
snapshot: account.portfolio
|
||||||
? {
|
? savedSnapshot(account.portfolio)
|
||||||
...(account.portfolio.payload as unknown as PortfolioDto),
|
|
||||||
positions: (account.portfolio.payload as unknown as PortfolioDto).positions.map((position) => ({
|
|
||||||
...position,
|
|
||||||
sector: position.sector ?? null,
|
|
||||||
issuer: position.issuer ?? null,
|
|
||||||
})),
|
|
||||||
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
|
|
||||||
isStale:
|
|
||||||
Date.now() - account.portfolio.fetchedAt.valueOf() >=
|
|
||||||
portfolioFreshForMs,
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
||||||
@@ -326,3 +318,17 @@ export class PortfolioService {
|
|||||||
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt, classifications);
|
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt, classifications);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function savedSnapshot(saved: { fetchedAt: Date; payload: Prisma.JsonValue }): PortfolioDto {
|
||||||
|
const snapshot = portfolioSnapshot(saved.payload);
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
positions: snapshot.positions.map((position) => ({
|
||||||
|
...position,
|
||||||
|
sector: position.sector ?? null,
|
||||||
|
issuer: position.issuer ?? null,
|
||||||
|
})),
|
||||||
|
lastSuccessfulAt: saved.fetchedAt.toISOString(),
|
||||||
|
isStale: Date.now() - saved.fetchedAt.valueOf() >= portfolioFreshForMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import { toJson } from '../../../../prisma/to-json';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
const FIRST_BATCH_OFFSET = 0;
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export class ForecastRepository {
|
|||||||
membershipRevision: number;
|
membershipRevision: number;
|
||||||
portfolioProjection: string;
|
portfolioProjection: string;
|
||||||
portfolios: { accountId: string; dataRevision: number }[];
|
portfolios: { accountId: string; dataRevision: number }[];
|
||||||
|
replacesLatest?: boolean;
|
||||||
revision: string;
|
revision: string;
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
sources: ForecastSourceVersion[];
|
sources: ForecastSourceVersion[];
|
||||||
@@ -51,32 +53,30 @@ export class ForecastRepository {
|
|||||||
const previous = await tx.tbankForecast.findUnique({
|
const previous = await tx.tbankForecast.findUnique({
|
||||||
where: { userId_revision_scopeKey: key },
|
where: { userId_revision_scopeKey: key },
|
||||||
});
|
});
|
||||||
|
// An events read serves the account's latest complete forecast whatever
|
||||||
|
// the requested window; other scopes keep their exact-key row.
|
||||||
const requestedWindow = eventsWindow(input.scopeKey);
|
const requestedWindow = eventsWindow(input.scopeKey);
|
||||||
const saved = previous ?? (requestedWindow
|
const latest = requestedWindow
|
||||||
? (
|
? await tx.tbankForecast.findFirst({
|
||||||
await tx.tbankForecast.findMany({
|
where: {
|
||||||
where: {
|
...owner,
|
||||||
...owner,
|
// scopeKey is `events/[accountId,from,to]` JSON: pin the account.
|
||||||
scopeKey: { startsWith: 'events/' },
|
scopeKey: {
|
||||||
membershipRevision: input.membershipRevision,
|
startsWith: `events/[${JSON.stringify(requestedWindow.accountId)},`,
|
||||||
},
|
},
|
||||||
})
|
membershipRevision: input.membershipRevision,
|
||||||
).find((forecast) => {
|
},
|
||||||
const window = eventsWindow(forecast.scopeKey);
|
orderBy: [{ calculatedAt: 'desc' }, { scopeKey: 'asc' }],
|
||||||
return (
|
|
||||||
window &&
|
|
||||||
window.accountId === requestedWindow.accountId &&
|
|
||||||
window.from <= requestedWindow.to &&
|
|
||||||
requestedWindow.from <= window.to
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
: null);
|
: previous;
|
||||||
const fallback =
|
const fallback =
|
||||||
saved?.membershipRevision === input.membershipRevision
|
latest?.membershipRevision === input.membershipRevision ? latest : null;
|
||||||
? saved
|
|
||||||
: null;
|
|
||||||
if (!input.candidate) return fallback;
|
if (!input.candidate) return fallback;
|
||||||
if (!previous && fallback) return fallback;
|
// Only the default window may replace the latest forecast; an explicit
|
||||||
|
// range is served from it and publishes only when nothing is saved.
|
||||||
|
if (!input.replacesLatest && fallback && fallback.scopeKey !== input.scopeKey) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
const portfolios = await tx.tbankPortfolio.findMany({
|
const portfolios = await tx.tbankPortfolio.findMany({
|
||||||
where: {
|
where: {
|
||||||
...owner,
|
...owner,
|
||||||
@@ -143,16 +143,14 @@ export class ForecastRepository {
|
|||||||
) {
|
) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
const revisions = JSON.parse(
|
const revisions = toJson({
|
||||||
JSON.stringify({
|
portfolioProjection: input.portfolioProjection,
|
||||||
portfolioProjection: input.portfolioProjection,
|
portfolios: input.portfolios,
|
||||||
portfolios: input.portfolios,
|
sources: input.sources,
|
||||||
sources: input.sources,
|
});
|
||||||
}),
|
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
const timestamps = input.timestamps as Prisma.InputJsonArray;
|
const timestamps = input.timestamps as Prisma.InputJsonArray;
|
||||||
if (
|
if (
|
||||||
fallback &&
|
fallback?.scopeKey === input.scopeKey &&
|
||||||
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
|
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
|
||||||
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
|
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
|
||||||
) {
|
) {
|
||||||
@@ -160,9 +158,7 @@ export class ForecastRepository {
|
|||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
membershipRevision: input.membershipRevision,
|
membershipRevision: input.membershipRevision,
|
||||||
payload: JSON.parse(
|
payload: toJson(input.candidate),
|
||||||
JSON.stringify(input.candidate),
|
|
||||||
) as Prisma.InputJsonObject,
|
|
||||||
dependencyRevisions: revisions,
|
dependencyRevisions: revisions,
|
||||||
sourceTimestamps: timestamps,
|
sourceTimestamps: timestamps,
|
||||||
calculatedAt: new Date(),
|
calculatedAt: new Date(),
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import type { Prisma } from '../../../../../generated/prisma/client';
|
|||||||
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import { tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api';
|
import { lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim';
|
||||||
|
import { isJoinableRunningAttempt, tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import { toJson } from '../../../../prisma/to-json';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
||||||
|
|
||||||
@@ -56,16 +58,10 @@ export class PortfolioRepository {
|
|||||||
force: boolean,
|
force: boolean,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, { userId, revision }, {
|
||||||
where: {
|
accounts: { some: { accountId } },
|
||||||
userId,
|
|
||||||
revision,
|
|
||||||
invalidToken: false,
|
|
||||||
accounts: { some: { accountId } },
|
|
||||||
},
|
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
});
|
||||||
if (!current.count) return null;
|
if (!current) return null;
|
||||||
const snapshot = await tx.tbankPortfolio.findUnique({
|
const snapshot = await tx.tbankPortfolio.findUnique({
|
||||||
where: { userId_revision_accountId: { userId, revision, accountId } },
|
where: { userId_revision_accountId: { userId, revision, accountId } },
|
||||||
});
|
});
|
||||||
@@ -86,11 +82,7 @@ export class PortfolioRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const scope = portfolioWorkScope;
|
const scope = portfolioWorkScope;
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, processRunId, pending)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === processRunId &&
|
|
||||||
pending.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -137,7 +129,7 @@ export class PortfolioRepository {
|
|||||||
accountId,
|
accountId,
|
||||||
};
|
};
|
||||||
const data = {
|
const data = {
|
||||||
payload: JSON.parse(JSON.stringify(snapshot)) as Prisma.InputJsonObject,
|
payload: toJson(snapshot),
|
||||||
fetchedAt: new Date(snapshot.lastSuccessfulAt),
|
fetchedAt: new Date(snapshot.lastSuccessfulAt),
|
||||||
};
|
};
|
||||||
await tx.tbankPortfolio.upsert({
|
await tx.tbankPortfolio.upsert({
|
||||||
@@ -200,3 +192,16 @@ export class PortfolioRepository {
|
|||||||
export function portfolioWorkKey(accountId: string): string {
|
export function portfolioWorkKey(accountId: string): string {
|
||||||
return `portfolio:${JSON.stringify(accountId)}`;
|
return `portfolio:${JSON.stringify(accountId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Json payload is written only by publish(); a shape check turns corruption into an explicit 500.
|
||||||
|
export function portfolioSnapshot(value: Prisma.JsonValue): PortfolioDto {
|
||||||
|
const snapshot = value as null | Partial<PortfolioDto>;
|
||||||
|
if (
|
||||||
|
!Array.isArray(snapshot?.positions) ||
|
||||||
|
!Array.isArray(snapshot.totalsByCurrency) ||
|
||||||
|
!Array.isArray(snapshot.cashBalances)
|
||||||
|
) {
|
||||||
|
throw new Error('Corrupted portfolio snapshot payload');
|
||||||
|
}
|
||||||
|
return snapshot as PortfolioDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
account.accountId,
|
account.accountId,
|
||||||
dispatch,
|
dispatch,
|
||||||
dispatchProblems,
|
dispatchProblems,
|
||||||
|
walkStartedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,9 +341,12 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
dispatch: DispatchStep,
|
dispatch: DispatchStep,
|
||||||
dispatchProblems: HourlySection[],
|
dispatchProblems: HourlySection[],
|
||||||
|
walkStartedAt: Date,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const ids = await this.withBound(this.events.refresh(userId, accountId));
|
const ids = await this.withBound(
|
||||||
|
this.events.refresh(userId, accountId, {}, walkStartedAt),
|
||||||
|
);
|
||||||
if (ids !== BOUND_EXPIRED) {
|
if (ids !== BOUND_EXPIRED) {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
await dispatch({ accountId, id, section: 'events' });
|
await dispatch({ accountId, id, section: 'events' });
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
|
PrismaService: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
hourlyProgress,
|
||||||
|
screenIntent,
|
||||||
|
screenProgress,
|
||||||
|
} from './tbank-sync.repository';
|
||||||
|
|
||||||
|
describe('screen sync intent', () => {
|
||||||
|
it('restores a persisted intent and rejects a corrupted one', () => {
|
||||||
|
const intent = { now: '2026-11-02T00:00:00.000Z', scope: { screen: 'accounts' } };
|
||||||
|
expect(screenIntent(intent)).toEqual(intent);
|
||||||
|
expect(() => screenIntent({ source: 'hourly' })).toThrow('Corrupted screen sync intent');
|
||||||
|
expect(() => screenIntent(null)).toThrow('Corrupted screen sync intent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('screen sync progress', () => {
|
||||||
|
it('restores a persisted progress unchanged', () => {
|
||||||
|
const progress = { attempts: { 'work-1': 'attempt-1' }, children: ['work-1'], dispatched: true };
|
||||||
|
expect(screenProgress(progress)).toEqual(progress);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates legacy rows without attempts', () => {
|
||||||
|
expect(screenProgress({ children: [], dispatched: false })).toEqual({
|
||||||
|
children: [],
|
||||||
|
dispatched: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects corrupt rows with an explicit error', () => {
|
||||||
|
expect(() => screenProgress(null)).toThrow('Corrupted screen sync progress');
|
||||||
|
expect(() => screenProgress({})).toThrow('Corrupted screen sync progress');
|
||||||
|
expect(() => screenProgress({ children: 'x', dispatched: false })).toThrow(
|
||||||
|
'Corrupted screen sync progress',
|
||||||
|
);
|
||||||
|
expect(() => screenProgress({ children: [1], dispatched: false })).toThrow(
|
||||||
|
'Corrupted screen sync progress',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => screenProgress({ children: [], dispatched: 'yes' }),
|
||||||
|
).toThrow('Corrupted screen sync progress');
|
||||||
|
expect(
|
||||||
|
() => screenProgress({ children: [], dispatched: false, attempts: { w: 7 } }),
|
||||||
|
).toThrow('Corrupted screen sync progress');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hourly sync progress', () => {
|
||||||
|
it('restores a persisted progress unchanged', () => {
|
||||||
|
const progress = {
|
||||||
|
attempts: { 'work-1': 'attempt-1' },
|
||||||
|
children: [{ id: 'work-1', section: 'portfolio' }],
|
||||||
|
result: 'partial',
|
||||||
|
sections: [{ errorCode: null, section: 'events' }],
|
||||||
|
};
|
||||||
|
expect(hourlyProgress(progress)).toEqual(progress);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates legacy rows without optional collections', () => {
|
||||||
|
expect(hourlyProgress({ sections: [] })).toEqual({ sections: [] });
|
||||||
|
// Legacy hourly rows were seeded with an empty progress object.
|
||||||
|
expect(hourlyProgress({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects corrupt rows with an explicit error', () => {
|
||||||
|
expect(() => hourlyProgress(null)).toThrow('Corrupted hourly sync progress');
|
||||||
|
expect(() => hourlyProgress({ sections: 'x' })).toThrow('Corrupted hourly sync progress');
|
||||||
|
expect(() => hourlyProgress({ sections: [{ errorCode: 'x', section: 1 }] })).toThrow(
|
||||||
|
'Corrupted hourly sync progress',
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
hourlyProgress({ sections: [], children: [{ id: 'w', section: 5 }] }),
|
||||||
|
).toThrow('Corrupted hourly sync progress');
|
||||||
|
expect(() => hourlyProgress({ sections: [], attempts: { w: 7 } })).toThrow(
|
||||||
|
'Corrupted hourly sync progress',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,13 +6,49 @@ import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
|
|||||||
import type { InvestmentAttemptDto } from '../investment-resource.dto';
|
import type { InvestmentAttemptDto } from '../investment-resource.dto';
|
||||||
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||||
|
|
||||||
|
import { claimSyncWork, lockConnectionGeneration } from '../../../integrations/tbank/tbank-sync-claim';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { toJson } from '../../../prisma/to-json';
|
||||||
export type ScreenIntent = { now: string; scope: InvestmentScreenDto };
|
export type ScreenIntent = { now: string; scope: InvestmentScreenDto };
|
||||||
|
|
||||||
|
// Json columns are written only by this repository; a shape check turns corruption into an explicit 500.
|
||||||
|
export function screenIntent(value: Prisma.JsonValue): ScreenIntent {
|
||||||
|
const intent = value as null | Partial<ScreenIntent>;
|
||||||
|
if (typeof intent?.now !== 'string' || typeof intent.scope?.screen !== 'string') {
|
||||||
|
throw new Error('Corrupted screen sync intent');
|
||||||
|
}
|
||||||
|
return intent as ScreenIntent;
|
||||||
|
}
|
||||||
export type ScreenProgress = {
|
export type ScreenProgress = {
|
||||||
attempts?: Record<string, string>;
|
attempts?: Record<string, string>;
|
||||||
children: string[];
|
children: string[];
|
||||||
dispatched: boolean;
|
dispatched: boolean;
|
||||||
};
|
};
|
||||||
|
// Progress columns are written only by this repository; a shape check turns
|
||||||
|
// corruption into an explicit 500. Legacy rows predate optional fields, so
|
||||||
|
// only the fields every writer has always written are required.
|
||||||
|
function validAttemptMap(attempts: unknown): boolean {
|
||||||
|
return (
|
||||||
|
attempts === undefined ||
|
||||||
|
(typeof attempts === 'object' &&
|
||||||
|
attempts !== null &&
|
||||||
|
!Array.isArray(attempts) &&
|
||||||
|
Object.values(attempts).every((id) => typeof id === 'string'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function screenProgress(value: Prisma.JsonValue): ScreenProgress {
|
||||||
|
const progress = value as null | Partial<ScreenProgress>;
|
||||||
|
if (
|
||||||
|
typeof progress?.dispatched !== 'boolean' ||
|
||||||
|
!Array.isArray(progress.children) ||
|
||||||
|
!progress.children.every((child) => typeof child === 'string') ||
|
||||||
|
!validAttemptMap(progress.attempts)
|
||||||
|
) {
|
||||||
|
throw new Error('Corrupted screen sync progress');
|
||||||
|
}
|
||||||
|
return progress as ScreenProgress;
|
||||||
|
}
|
||||||
export type HourlySection = {
|
export type HourlySection = {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
errorCode: null | string;
|
errorCode: null | string;
|
||||||
@@ -34,6 +70,42 @@ export type HourlyProgress = {
|
|||||||
result?: 'failed' | 'partial' | 'succeeded';
|
result?: 'failed' | 'partial' | 'succeeded';
|
||||||
sections: HourlySection[];
|
sections: HourlySection[];
|
||||||
};
|
};
|
||||||
|
// Same tolerance rule as screenProgress: required fields are the ones every
|
||||||
|
// hourly writer has always written; optional collections may be absent.
|
||||||
|
export function hourlyProgress(value: Prisma.JsonValue): HourlyProgress {
|
||||||
|
const progress: unknown = value;
|
||||||
|
if (typeof progress !== 'object' || progress === null) {
|
||||||
|
throw new Error('Corrupted hourly sync progress');
|
||||||
|
}
|
||||||
|
const row = progress as Record<string, unknown>;
|
||||||
|
const validChildren =
|
||||||
|
row.children === undefined ||
|
||||||
|
(Array.isArray(row.children) &&
|
||||||
|
row.children.every((child) => {
|
||||||
|
if (typeof child !== 'object' || child === null) return false;
|
||||||
|
const entry = child as Record<string, unknown>;
|
||||||
|
return typeof entry.id === 'string' && typeof entry.section === 'string';
|
||||||
|
}));
|
||||||
|
const validSections =
|
||||||
|
row.sections === undefined ||
|
||||||
|
(Array.isArray(row.sections) &&
|
||||||
|
row.sections.every((section) => {
|
||||||
|
if (typeof section !== 'object' || section === null) return false;
|
||||||
|
const entry = section as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof entry.section === 'string' &&
|
||||||
|
(typeof entry.errorCode === 'string' || entry.errorCode === null)
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
if (
|
||||||
|
!validSections ||
|
||||||
|
!validAttemptMap(row.attempts) ||
|
||||||
|
!validChildren
|
||||||
|
) {
|
||||||
|
throw new Error('Corrupted hourly sync progress');
|
||||||
|
}
|
||||||
|
return progress as HourlyProgress;
|
||||||
|
}
|
||||||
type HourlyAttempt = {
|
type HourlyAttempt = {
|
||||||
finishedAt: Date | null;
|
finishedAt: Date | null;
|
||||||
sections: HourlySection[];
|
sections: HourlySection[];
|
||||||
@@ -79,18 +151,15 @@ export class TbankSyncRepository {
|
|||||||
runId: string,
|
runId: string,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const valid = await tx.tbankConnection.updateMany({
|
const valid = await lockConnectionGeneration(tx, { userId, revision });
|
||||||
where: { userId, revision, invalidToken: false },
|
if (!valid) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!valid.count) return null;
|
|
||||||
const scopeKey = `screen:${JSON.stringify(scope)}`;
|
const scopeKey = `screen:${JSON.stringify(scope)}`;
|
||||||
const key = { userId, revision, scopeKey };
|
const key = { userId, revision, scopeKey };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: key },
|
where: { userId_revision_scopeKey: key },
|
||||||
});
|
});
|
||||||
if (previous?.state === 'running' && previous.processRunId === runId) {
|
if (previous?.state === 'running' && previous.processRunId === runId) {
|
||||||
const progress = previous.progress as ScreenProgress;
|
const progress = screenProgress(previous.progress);
|
||||||
const children = progress.children ?? [];
|
const children = progress.children ?? [];
|
||||||
if (
|
if (
|
||||||
!progress.dispatched ||
|
!progress.dispatched ||
|
||||||
@@ -106,9 +175,7 @@ export class TbankSyncRepository {
|
|||||||
processRunId: runId,
|
processRunId: runId,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
intent: JSON.parse(
|
intent: toJson({ scope, now: new Date().toISOString() }),
|
||||||
JSON.stringify({ scope, now: new Date().toISOString() }),
|
|
||||||
) as Prisma.InputJsonObject,
|
|
||||||
progress: { children: [], dispatched: false },
|
progress: { children: [], dispatched: false },
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
};
|
};
|
||||||
@@ -127,7 +194,7 @@ export class TbankSyncRepository {
|
|||||||
where: { userId, revision, id },
|
where: { userId, revision, id },
|
||||||
});
|
});
|
||||||
if (!work) return null;
|
if (!work) return null;
|
||||||
const progress = work.progress as ScreenProgress;
|
const progress = screenProgress(work.progress);
|
||||||
const children = await tx.tbankSyncWork.findMany({
|
const children = await tx.tbankSyncWork.findMany({
|
||||||
where: { userId, revision, id: { in: progress.children ?? [] } },
|
where: { userId, revision, id: { in: progress.children ?? [] } },
|
||||||
select: { id: true, attemptId: true, state: true, errorCode: true },
|
select: { id: true, attemptId: true, state: true, errorCode: true },
|
||||||
@@ -154,7 +221,7 @@ export class TbankSyncRepository {
|
|||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const previous = await tx.tbankSyncWork.findFirst({ where: attempt });
|
const previous = await tx.tbankSyncWork.findFirst({ where: attempt });
|
||||||
if (!previous) return { count: 0 };
|
if (!previous) return { count: 0 };
|
||||||
const old = previous.progress as ScreenProgress;
|
const old = screenProgress(previous.progress);
|
||||||
const rows = await tx.tbankSyncWork.findMany({
|
const rows = await tx.tbankSyncWork.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: attempt.userId,
|
userId: attempt.userId,
|
||||||
@@ -167,17 +234,11 @@ export class TbankSyncRepository {
|
|||||||
...Object.fromEntries(rows.map((row) => [row.id, row.attemptId])),
|
...Object.fromEntries(rows.map((row) => [row.id, row.attemptId])),
|
||||||
...old.attempts,
|
...old.attempts,
|
||||||
};
|
};
|
||||||
return tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
progress: { children: [...new Set(children)], attempts, dispatched },
|
||||||
...attempt,
|
...(errorCode ? { state: 'failed', errorCode } : {}),
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
progress: { children: [...new Set(children)], attempts, dispatched },
|
|
||||||
...(errorCode ? { state: 'failed', errorCode } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return { count: claimed };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,10 +287,8 @@ export class TbankSyncRepository {
|
|||||||
processRunId: runId,
|
processRunId: runId,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
intent: JSON.parse(
|
intent: { source: 'hourly' },
|
||||||
JSON.stringify({ source: 'hourly' }),
|
progress: { sections: [] },
|
||||||
) as Prisma.InputJsonObject,
|
|
||||||
progress: { sections: [] } as unknown as Prisma.InputJsonValue,
|
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
};
|
};
|
||||||
const work = await tx.tbankSyncWork.upsert({
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
@@ -288,7 +347,7 @@ export class TbankSyncRepository {
|
|||||||
cause: HourlyCause | null,
|
cause: HourlyCause | null,
|
||||||
dispatchProblems: HourlySection[],
|
dispatchProblems: HourlySection[],
|
||||||
) {
|
) {
|
||||||
const progress = work.progress as HourlyProgress;
|
const progress = hourlyProgress(work.progress);
|
||||||
const children = progress.children ?? [];
|
const children = progress.children ?? [];
|
||||||
const rows = children.length
|
const rows = children.length
|
||||||
? await tx.tbankSyncWork.findMany({
|
? await tx.tbankSyncWork.findMany({
|
||||||
@@ -352,25 +411,22 @@ export class TbankSyncRepository {
|
|||||||
select: { progress: true },
|
select: { progress: true },
|
||||||
});
|
});
|
||||||
if (!work) return false;
|
if (!work) return false;
|
||||||
const progress = work.progress as HourlyProgress;
|
const progress = hourlyProgress(work.progress);
|
||||||
const row = await tx.tbankSyncWork.findFirst({
|
const row = await tx.tbankSyncWork.findFirst({
|
||||||
where: { userId: attempt.userId, id: child.id },
|
where: { userId: attempt.userId, id: child.id },
|
||||||
select: { attemptId: true },
|
select: { attemptId: true },
|
||||||
});
|
});
|
||||||
const updated = await tx.tbankSyncWork.updateMany({
|
const updated = await claimSyncWork(tx, attempt, {
|
||||||
where: { ...attempt, state: 'running' },
|
progress: {
|
||||||
data: {
|
attempts: {
|
||||||
progress: {
|
...progress.attempts,
|
||||||
attempts: {
|
...(row ? { [child.id]: row.attemptId } : {}),
|
||||||
...progress.attempts,
|
|
||||||
...(row ? { [child.id]: row.attemptId } : {}),
|
|
||||||
},
|
|
||||||
children: [...(progress.children ?? []), child],
|
|
||||||
sections: progress.sections ?? [],
|
|
||||||
},
|
},
|
||||||
|
children: [...(progress.children ?? []), child],
|
||||||
|
sections: progress.sections ?? [],
|
||||||
},
|
},
|
||||||
});
|
}, { withoutConnectionFence: true });
|
||||||
return Boolean(updated.count);
|
return Boolean(updated);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +453,7 @@ export class TbankSyncRepository {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!work) return null;
|
if (!work) return null;
|
||||||
const progress = work.progress as HourlyProgress;
|
const progress = hourlyProgress(work.progress);
|
||||||
let state: 'cancelled' | 'running' | HourlyProgress['result'];
|
let state: 'cancelled' | 'running' | HourlyProgress['result'];
|
||||||
if (work.state === 'running') state = 'running';
|
if (work.state === 'running') state = 'running';
|
||||||
else if (work.state === 'cancelled') state = 'cancelled';
|
else if (work.state === 'cancelled') state = 'cancelled';
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { TbankAccessService } from '../../../integrations/tbank/tbank.api';
|
import { TbankAccessService } from '../../../integrations/tbank/tbank.api';
|
||||||
import { BrokerageOperationsSyncService } from '../operations/operations.api';
|
import { BrokerageOperationsSyncService } from '../operations/operations.api';
|
||||||
import { TbankSyncStatusDto } from './tbank-sync.dto';
|
import { TbankSyncStatusDto } from './tbank-sync.dto';
|
||||||
import {
|
import { screenIntent, TbankSyncRepository } from './tbank-sync.repository';
|
||||||
type ScreenIntent,
|
|
||||||
TbankSyncRepository,
|
|
||||||
} from './tbank-sync.repository';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TbankSyncService {
|
export class TbankSyncService {
|
||||||
@@ -35,7 +32,7 @@ export class TbankSyncService {
|
|||||||
if (!saved.work.scopeKey.startsWith('screen:')) {
|
if (!saved.work.scopeKey.startsWith('screen:')) {
|
||||||
return this.sourceStatus(userId, saved.work);
|
return this.sourceStatus(userId, saved.work);
|
||||||
}
|
}
|
||||||
const intent = saved.work.intent as unknown as ScreenIntent;
|
const intent = screenIntent(saved.work.intent);
|
||||||
await this.access.local(userId, intent.scope.accountId);
|
await this.access.local(userId, intent.scope.accountId);
|
||||||
const children = await Promise.all(
|
const children = await Promise.all(
|
||||||
saved.children.map((child) =>
|
saved.children.map((child) =>
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ export class UpcomingEventDto {
|
|||||||
declare projectedAmount: MoneyDto | null;
|
declare projectedAmount: MoneyDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpcomingEventsWindowDto {
|
||||||
|
@ApiProperty({ format: 'date' })
|
||||||
|
declare from: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date' })
|
||||||
|
declare to: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpcomingEventsDto {
|
export class UpcomingEventsDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
declare accountId: string;
|
declare accountId: string;
|
||||||
@@ -72,6 +80,13 @@ export class UpcomingEventsDto {
|
|||||||
@ApiProperty({ format: 'date-time' })
|
@ApiProperty({ format: 'date-time' })
|
||||||
declare calculatedAt: string;
|
declare calculatedAt: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: UpcomingEventsWindowDto,
|
||||||
|
description:
|
||||||
|
'Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter.',
|
||||||
|
})
|
||||||
|
declare window: UpcomingEventsWindowDto;
|
||||||
|
|
||||||
@ApiProperty({ type: UpcomingEventDto, isArray: true })
|
@ApiProperty({ type: UpcomingEventDto, isArray: true })
|
||||||
declare events: UpcomingEventDto[];
|
declare events: UpcomingEventDto[];
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export class UpcomingEventsService {
|
|||||||
a.type.localeCompare(b.type) ||
|
a.type.localeCompare(b.type) ||
|
||||||
a.ticker.localeCompare(b.ticker),
|
a.ticker.localeCompare(b.ticker),
|
||||||
);
|
);
|
||||||
const candidate: undefined | UpcomingEventsDto = ready
|
const candidate: Omit<UpcomingEventsDto, 'window'> | undefined = ready
|
||||||
? {
|
? {
|
||||||
accountId,
|
accountId,
|
||||||
calculatedAt: new Date().toISOString(),
|
calculatedAt: new Date().toISOString(),
|
||||||
@@ -163,6 +163,7 @@ export class UpcomingEventsService {
|
|||||||
revision: connection.revision,
|
revision: connection.revision,
|
||||||
membershipRevision: connection.membershipRevision,
|
membershipRevision: connection.membershipRevision,
|
||||||
scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`,
|
scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`,
|
||||||
|
replacesLatest: query.from === undefined && query.to === undefined,
|
||||||
portfolio: portfolio.meta,
|
portfolio: portfolio.meta,
|
||||||
lastAttempt: hourlyAttempt(
|
lastAttempt: hourlyAttempt(
|
||||||
hourly,
|
hourly,
|
||||||
@@ -184,6 +185,7 @@ export class UpcomingEventsService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
query: UpcomingEventsQueryDto = {},
|
query: UpcomingEventsQueryDto = {},
|
||||||
|
walkStartedAt?: Date,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const window = dateWindow(query);
|
const window = dateWindow(query);
|
||||||
await this.access.local(userId, accountId);
|
await this.access.local(userId, accountId);
|
||||||
@@ -194,6 +196,7 @@ export class UpcomingEventsService {
|
|||||||
positions,
|
positions,
|
||||||
window,
|
window,
|
||||||
false,
|
false,
|
||||||
|
walkStartedAt,
|
||||||
);
|
);
|
||||||
return launched;
|
return launched;
|
||||||
}
|
}
|
||||||
@@ -203,6 +206,7 @@ export class UpcomingEventsService {
|
|||||||
positions: Position[],
|
positions: Position[],
|
||||||
window: { from: string; to: string },
|
window: { from: string; to: string },
|
||||||
observe: boolean,
|
observe: boolean,
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const metadata = await this.metadata.savedMany(positions);
|
const metadata = await this.metadata.savedMany(positions);
|
||||||
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
||||||
@@ -228,6 +232,7 @@ export class UpcomingEventsService {
|
|||||||
identities.map((entry) => [entry.uid, entry.ticker]),
|
identities.map((entry) => [entry.uid, entry.ticker]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
refreshBefore,
|
||||||
);
|
);
|
||||||
sources.push(...batch);
|
sources.push(...batch);
|
||||||
for (const source of batch) {
|
for (const source of batch) {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { toJson } from './to-json';
|
||||||
|
|
||||||
|
it('produces the same bytes as the inline JSON round-trip', () => {
|
||||||
|
const value = {
|
||||||
|
a: 'x',
|
||||||
|
b: undefined,
|
||||||
|
c: new Date('2026-11-04T00:00:00.000Z'),
|
||||||
|
d: [{ e: 1 }],
|
||||||
|
};
|
||||||
|
expect(toJson(value)).toEqual(
|
||||||
|
JSON.parse(JSON.stringify(value)) as object,
|
||||||
|
);
|
||||||
|
expect(toJson(value)).toEqual({ a: 'x', c: '2026-11-04T00:00:00.000Z', d: [{ e: 1 }] });
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Prisma } from '../../generated/prisma/client';
|
||||||
|
|
||||||
|
// Single owner for the Prisma Json write codec (backend#170): a JSON
|
||||||
|
// round-trip drops undefined values and Date instances become ISO strings,
|
||||||
|
// which is exactly what the Json columns expect. Same operations as the
|
||||||
|
// inline copies it replaced, so written bytes are identical.
|
||||||
|
export function toJson<T extends object>(value: T): Prisma.InputJsonObject {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonObject;
|
||||||
|
}
|
||||||
@@ -102,6 +102,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur
|
|||||||
[controller, 'import { PrismaService } from "../../../prisma/prisma.service";'],
|
[controller, 'import { PrismaService } from "../../../prisma/prisma.service";'],
|
||||||
[controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'],
|
[controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'],
|
||||||
[useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'],
|
[useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'],
|
||||||
|
['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'],
|
||||||
]) {
|
]) {
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
assert.equal(result.fatalErrorCount, 0);
|
assert.equal(result.fatalErrorCount, 0);
|
||||||
@@ -113,7 +114,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur
|
|||||||
[controller, 'import { AuthService } from "../application/auth.service";'],
|
[controller, 'import { AuthService } from "../application/auth.service";'],
|
||||||
[useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'],
|
[useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'],
|
||||||
[useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'],
|
[useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'],
|
||||||
['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'],
|
['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import type { Prisma } from "../../../../../generated/prisma/client";'],
|
||||||
]) {
|
]) {
|
||||||
const [result] = await eslint.lintText(code, { filePath });
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
assert.equal(result.fatalErrorCount, 0);
|
assert.equal(result.fatalErrorCount, 0);
|
||||||
|
|||||||
@@ -640,8 +640,6 @@ describe('hourly attempt lifecycle (SQLite)', () => {
|
|||||||
{ getLastPrices: jest.fn().mockResolvedValue([]) } as never,
|
{ getLastPrices: jest.fn().mockResolvedValue([]) } as never,
|
||||||
logger as never,
|
logger as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
);
|
||||||
jest
|
jest
|
||||||
.spyOn(catalogRepository, 'publishSource')
|
.spyOn(catalogRepository, 'publishSource')
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
|
||||||
|
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
|
||||||
|
// Characterizes the membership reserve protocol on real SQLite transactions.
|
||||||
|
// Join by the same process while the attempt is active; launch after it is not.
|
||||||
|
describe('membership reservation protocol (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 access = new TbankAccessService(prisma, tokens);
|
||||||
|
const userId = 'synthetic-membership-reservation';
|
||||||
|
const revision = `${userId}-revision`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.$connect();
|
||||||
|
await prisma.tbankSyncWork.deleteMany({ where: { userId } });
|
||||||
|
await prisma.tbankConnection.deleteMany({ where: { userId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: userId } });
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: userId,
|
||||||
|
email: `${userId}@example.invalid`,
|
||||||
|
passwordHash: 'synthetic',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.tbankConnection.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
encryptedToken: tokens.encrypt('synthetic-membership'),
|
||||||
|
connectedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.tbankSyncWork.deleteMany({ where: { userId } });
|
||||||
|
await prisma.tbankConnection.deleteMany({ where: { userId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: userId } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('launches once, joins the active attempt by the same process, and relaunches after it finishes', async () => {
|
||||||
|
const processRunId = 'run-membership-a';
|
||||||
|
const first = await access.reserveMembership(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
processRunId,
|
||||||
|
new Set(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(first?.launch).toBe(true);
|
||||||
|
const attemptId = first?.work.attemptId ?? '';
|
||||||
|
|
||||||
|
const joined = await access.reserveMembership(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
processRunId,
|
||||||
|
new Set([attemptId]),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(joined?.launch).toBe(false);
|
||||||
|
expect(joined?.work.id).toBe(first?.work.id);
|
||||||
|
expect(joined?.work.attemptId).toBe(attemptId);
|
||||||
|
|
||||||
|
// The attempt is no longer in the active set: this must not join it.
|
||||||
|
const relaunched = await access.reserveMembership(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
processRunId,
|
||||||
|
new Set(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(relaunched?.launch).toBe(true);
|
||||||
|
expect(relaunched?.work.attemptId).not.toBe(attemptId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not join an attempt started by another process', async () => {
|
||||||
|
const other = await access.reserveMembership(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
'run-membership-b',
|
||||||
|
new Set(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(other?.launch).toBe(true);
|
||||||
|
const attemptId = other?.work.attemptId ?? '';
|
||||||
|
|
||||||
|
const foreign = await access.reserveMembership(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
'run-membership-c',
|
||||||
|
new Set([attemptId]),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(foreign?.launch).toBe(true);
|
||||||
|
expect(foreign?.work.attemptId).not.toBe(attemptId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -82,7 +82,10 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
|
`brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
const refresh = async (range = window, accountId = 'account') => {
|
const refresh = async (
|
||||||
|
range: { from?: string; to?: string } = window,
|
||||||
|
accountId = 'account',
|
||||||
|
) => {
|
||||||
await app.get(UpcomingEventsService).refresh('owner', accountId, range);
|
await app.get(UpcomingEventsService).refresh('owner', accountId, range);
|
||||||
await settled();
|
await settled();
|
||||||
};
|
};
|
||||||
@@ -318,17 +321,23 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
expect(upstream.getDividends).toHaveBeenCalledTimes(count);
|
expect(upstream.getDividends).toHaveBeenCalledTimes(count);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => {
|
it('serves a disjoint range as an empty saved result and never relabels a membership snapshot', async () => {
|
||||||
await refresh();
|
await refresh();
|
||||||
await settled();
|
await settled();
|
||||||
await events().expect(200);
|
const saved = (await events().expect(200))
|
||||||
|
.body as UpcomingEventsResourceDto;
|
||||||
const newRange = (
|
const newRange = (
|
||||||
await events(true, 'account', 'owner', {
|
await events(true, 'account', 'owner', {
|
||||||
from: '2099-03-01',
|
from: '2099-03-01',
|
||||||
to: '2099-03-31',
|
to: '2099-03-31',
|
||||||
}).expect(200)
|
}).expect(200)
|
||||||
).body as UpcomingEventsResourceDto;
|
).body as UpcomingEventsResourceDto;
|
||||||
expect(newRange.data).toBeNull();
|
expect(newRange.data).toEqual({
|
||||||
|
...saved.data,
|
||||||
|
events: [],
|
||||||
|
incomeTotalsByCurrency: [],
|
||||||
|
});
|
||||||
|
expect(newRange.data?.window).toEqual(window);
|
||||||
await prisma.tbankConnection.update({
|
await prisma.tbankConnection.update({
|
||||||
where: { userId: 'owner' },
|
where: { userId: 'owner' },
|
||||||
data: { membershipRevision: { increment: 1 } },
|
data: { membershipRevision: { increment: 1 } },
|
||||||
@@ -640,6 +649,247 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
expect(moex.bondEvents).not.toHaveBeenCalled();
|
expect(moex.bondEvents).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the latest wide forecast when an explicit narrower range has a complete candidate', async () => {
|
||||||
|
await refresh();
|
||||||
|
const full = (await events().expect(200))
|
||||||
|
.body as UpcomingEventsResourceDto;
|
||||||
|
const narrowRange = { from: '2099-02-01', to: '2099-02-10' };
|
||||||
|
await refresh(narrowRange);
|
||||||
|
|
||||||
|
const narrow = (await events(true, 'account', 'owner', narrowRange).expect(
|
||||||
|
200,
|
||||||
|
)).body as UpcomingEventsResourceDto;
|
||||||
|
|
||||||
|
expect(narrow.data?.calculatedAt).toBe(full.data?.calculatedAt);
|
||||||
|
expect(narrow.data?.window).toEqual(window);
|
||||||
|
expect(narrow.data?.events.map((event) => event.date)).toEqual([
|
||||||
|
'2099-02-01',
|
||||||
|
]);
|
||||||
|
expect(await prisma.tbankForecast.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('daily default window shift', () => {
|
||||||
|
const day0 = '2099-02-01';
|
||||||
|
const day1 = '2099-02-02';
|
||||||
|
const windowOf = (from: string, to: string) => ({ from, to });
|
||||||
|
const w0 = windowOf(day0, '2099-03-03');
|
||||||
|
const w1 = windowOf(day1, '2099-03-04');
|
||||||
|
const dividend = (date: string) => ({
|
||||||
|
recordDate: time(date),
|
||||||
|
dividendNet: { units: '10', nano: 1, currency: 'usd' },
|
||||||
|
});
|
||||||
|
const defaultEvents = async () =>
|
||||||
|
(await get('brokerage-accounts/account/events?sync=observe').expect(200))
|
||||||
|
.body as UpcomingEventsResourceDto;
|
||||||
|
// Only Date is faked: the app and SQLite helpers need real timers.
|
||||||
|
const clock = (date: string) => {
|
||||||
|
jest.useFakeTimers({
|
||||||
|
doNotFake: [
|
||||||
|
'hrtime', 'nextTick', 'performance', 'queueMicrotask',
|
||||||
|
'requestAnimationFrame', 'cancelAnimationFrame',
|
||||||
|
'requestIdleCallback', 'cancelIdleCallback', 'setImmediate',
|
||||||
|
'clearImmediate', 'setInterval', 'clearInterval', 'setTimeout',
|
||||||
|
'clearTimeout',
|
||||||
|
],
|
||||||
|
now: new Date(`${date}T12:00:00Z`),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('publishes the new window as latest and serves it for stale or disjoint explicit ranges', async () => {
|
||||||
|
clock(day0);
|
||||||
|
upstream.getDividends.mockResolvedValue([
|
||||||
|
dividend('2099-02-05'),
|
||||||
|
dividend('2099-03-04'),
|
||||||
|
]);
|
||||||
|
await portfolio([position(shareUid, '2')]);
|
||||||
|
await refresh({});
|
||||||
|
const first = await defaultEvents();
|
||||||
|
expect(first.data?.window).toEqual(w0);
|
||||||
|
expect(first.data?.incomeTotalsByCurrency).toEqual([
|
||||||
|
{ currency: 'USD', value: '20.000000002' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
jest.setSystemTime(new Date(`${day1}T12:00:00Z`));
|
||||||
|
await portfolio([position(shareUid, '5')]);
|
||||||
|
await refresh({});
|
||||||
|
const second = await defaultEvents();
|
||||||
|
|
||||||
|
expect(second.data?.window).toEqual(w1);
|
||||||
|
expect(second.data?.calculatedAt).not.toBe(first.data?.calculatedAt);
|
||||||
|
expect(second.data?.events.map((event) => event.date)).toEqual([
|
||||||
|
'2099-02-05',
|
||||||
|
'2099-03-04',
|
||||||
|
]);
|
||||||
|
expect(second.data?.incomeTotalsByCurrency).toEqual([
|
||||||
|
{ currency: 'USD', value: '100.00000001' },
|
||||||
|
]);
|
||||||
|
const stale = (await events(true, 'account', 'owner', w0).expect(200))
|
||||||
|
.body as UpcomingEventsResourceDto;
|
||||||
|
expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt);
|
||||||
|
expect(stale.data?.window).toEqual(w1);
|
||||||
|
const disjoint = (
|
||||||
|
await events(true, 'account', 'owner', {
|
||||||
|
from: '2100-01-01',
|
||||||
|
to: '2100-01-31',
|
||||||
|
}).expect(200)
|
||||||
|
).body as UpcomingEventsResourceDto;
|
||||||
|
expect(disjoint.data).toEqual({
|
||||||
|
...second.data,
|
||||||
|
events: [],
|
||||||
|
incomeTotalsByCurrency: [],
|
||||||
|
});
|
||||||
|
expect(await prisma.tbankForecast.count()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the previous complete forecast when a holding calendar fails after the shift', async () => {
|
||||||
|
clock(day0);
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('2099-02-05')]);
|
||||||
|
await refresh({});
|
||||||
|
const before = await defaultEvents();
|
||||||
|
expect(before.data?.window).toEqual(w0);
|
||||||
|
|
||||||
|
jest.setSystemTime(new Date(`${day1}T12:00:00Z`));
|
||||||
|
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([dividend('2099-02-05')]),
|
||||||
|
);
|
||||||
|
await refresh({});
|
||||||
|
|
||||||
|
expect((await defaultEvents()).data).toEqual(before.data);
|
||||||
|
expect(await prisma.tbankForecast.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes the shifted window even when no source depends on it', async () => {
|
||||||
|
clock(day0);
|
||||||
|
await portfolio([]);
|
||||||
|
await refresh({});
|
||||||
|
expect((await defaultEvents()).data?.window).toEqual(w0);
|
||||||
|
|
||||||
|
jest.setSystemTime(new Date(`${day1}T12:00:00Z`));
|
||||||
|
await refresh({});
|
||||||
|
|
||||||
|
expect((await defaultEvents()).data?.window).toEqual(w1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hourly pass refreshes saved payout calendars', () => {
|
||||||
|
const date = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
||||||
|
const dividend = (units: string, dividendType = 'Regular') => ({
|
||||||
|
recordDate: time(date),
|
||||||
|
dividendType,
|
||||||
|
dividendNet: { units, currency: 'usd' },
|
||||||
|
});
|
||||||
|
// One hourly walk: the scheduler stamps it once and passes it to every account.
|
||||||
|
const startWalk = async () => {
|
||||||
|
await delay(10);
|
||||||
|
return new Date();
|
||||||
|
};
|
||||||
|
const pass = async (walkStartedAt: Date, accountId = 'account') => {
|
||||||
|
await app
|
||||||
|
.get(UpcomingEventsService)
|
||||||
|
.refresh('owner', accountId, {}, walkStartedAt);
|
||||||
|
await settled();
|
||||||
|
};
|
||||||
|
const latest = async () =>
|
||||||
|
(await get('brokerage-accounts/account/events?sync=observe').expect(200))
|
||||||
|
.body as UpcomingEventsResourceDto;
|
||||||
|
const dividendRow = () =>
|
||||||
|
prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||||
|
where: { instrumentUid: shareUid, kind: 'dividends' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks up a changed and then a cancelled payout on the next pass of the same window', async () => {
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('10')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
|
||||||
|
{ currency: 'USD', value: '20' },
|
||||||
|
]);
|
||||||
|
const first = await dividendRow();
|
||||||
|
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('30')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
|
||||||
|
{ currency: 'USD', value: '60' },
|
||||||
|
]);
|
||||||
|
expect((await dividendRow()).dataRevision).toBe(first.dataRevision + 1);
|
||||||
|
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('30', 'Cancelled')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect((await latest()).data?.events).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first complete calendar, time and forecast when the next refresh fails', async () => {
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('10')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
const before = await latest();
|
||||||
|
const row = await dividendRow();
|
||||||
|
|
||||||
|
upstream.getDividends.mockRejectedValue(new Error('outage'));
|
||||||
|
await pass(await startWalk());
|
||||||
|
const after = await latest();
|
||||||
|
|
||||||
|
expect(after.data).toEqual(before.data);
|
||||||
|
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
||||||
|
expect(
|
||||||
|
after.meta.dependencies?.some(
|
||||||
|
(dependency) => dependency.lastAttempt.state === 'failed',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
const kept = await dividendRow();
|
||||||
|
expect(kept.dataRevision).toBe(row.dataRevision);
|
||||||
|
expect(kept.fetchedAt).toEqual(row.fetchedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads one shared calendar once per walk across accounts and leaves detail, GET and a plain refresh alone', async () => {
|
||||||
|
await prisma.tbankAccount.create({
|
||||||
|
data: {
|
||||||
|
userId: 'owner',
|
||||||
|
revision: 'owner-revision',
|
||||||
|
accountId: 'second',
|
||||||
|
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||||
|
name: 'Second',
|
||||||
|
upstreamOrder: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await portfolio([position()], 'owner', 'second');
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('10')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
const detailCalls = () =>
|
||||||
|
upstream.getShare.mock.calls.length +
|
||||||
|
upstream.getInstrumentByUid.mock.calls.length;
|
||||||
|
const detailBefore = detailCalls();
|
||||||
|
expect(upstream.getDividends).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const walk = await startWalk();
|
||||||
|
await pass(walk, 'account');
|
||||||
|
await pass(walk, 'second');
|
||||||
|
expect(upstream.getDividends).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await app.get(UpcomingEventsService).refresh('owner', 'account', {});
|
||||||
|
await settled();
|
||||||
|
await latest();
|
||||||
|
await get('brokerage-accounts/all/events').expect(200);
|
||||||
|
expect(upstream.getDividends).toHaveBeenCalledTimes(2);
|
||||||
|
expect(detailCalls()).toBe(detailBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-reads the bond calendar whose key never changes, only on a walk pass', async () => {
|
||||||
|
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect(moex.bondEvents).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await app.get(UpcomingEventsService).refresh('owner', 'account', {});
|
||||||
|
await settled();
|
||||||
|
expect(moex.bondEvents).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect(moex.bondEvents).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => {
|
it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => {
|
||||||
let release: (value: unknown[]) => void = () => {};
|
let release: (value: unknown[]) => void = () => {};
|
||||||
upstream.getDividends.mockReturnValueOnce(
|
upstream.getDividends.mockReturnValueOnce(
|
||||||
|
|||||||
Reference in New Issue
Block a user