feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s

Store account membership, portfolio, operations, forecasts and market sources in SQLite.
Coalesce scoped refreshes, fence connection revisions, preserve complete financial
snapshots and stabilize operation period state.

BREAKING CHANGE: investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
This commit is contained in:
2026-09-26 21:29:15 +03:00
parent 0862d56c4e
commit d6e6c4ace5
116 changed files with 18321 additions and 5409 deletions
+225 -1
View File
@@ -1,9 +1,11 @@
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import {
SupportedTbankAccount,
@@ -22,6 +24,13 @@ export type TbankAccountContext = TbankConnectionContext & {
account: TbankAccount;
};
export type TbankSyncAttempt = {
userId: string;
revision: string;
id: string;
attemptId: string;
};
@Injectable()
export class TbankAccessService {
constructor(
@@ -30,6 +39,219 @@ export class TbankAccessService {
private readonly tbank: TbankClient,
) {}
async reserveMembership(
userId: string,
revision: string,
processRunId: string,
active: ReadonlySet<string>,
force: boolean,
) {
return this.prisma.$transaction(async (tx) => {
const current = await tx.tbankConnection.updateMany({
where: { userId, revision, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
const connection = await tx.tbankConnection.findUniqueOrThrow({
where: { userId },
});
if (
!force &&
!tbankSourceNeedsRefresh(
'membership',
connection.membershipAt,
new Date(),
)
)
return null;
const scope = { userId, revision, scopeKey: 'membership' };
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope },
});
if (
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
)
return { work: previous, launch: false };
const data = {
attemptId: randomUUID(),
processRunId,
state: 'running',
intent: { source: 'membership' },
progress: {},
priority: 0,
errorCode: null,
};
const work = await tx.tbankSyncWork.upsert({
where: { userId_revision_scopeKey: scope },
create: { ...scope, ...data, id: randomUUID() },
update: data,
});
return { work, launch: true };
});
}
membershipWork(userId: string, revision: string) {
return this.prisma.tbankSyncWork.findUnique({
where: {
userId_revision_scopeKey: { userId, revision, scopeKey: 'membership' },
},
select: { id: true, state: true, errorCode: true, updatedAt: true },
});
}
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
if (invalidToken) return this.rejectMembershipToken(attempt);
const result = await this.prisma.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
});
return result.count === 1;
}
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> {
const work = await this.prisma.tbankSyncWork.findFirst({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
select: { connection: { select: { encryptedToken: true } } },
});
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
}
async publishMembership(
attempt: TbankSyncAttempt,
accounts: TbankAccount[],
fetchedAt: Date,
completeWork = false,
): Promise<boolean> {
const supported = supportedTbankAccounts(accounts);
if (new Set(supported.map(({ id }) => id)).size !== supported.length)
throw new Error('Duplicate T-Bank account identity');
return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below.
const claimed = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { updatedAt: new Date() },
});
if (!claimed.count) return false;
const { userId, revision } = attempt;
const previous = await tx.tbankAccount.findMany({
where: { userId, revision },
orderBy: { upstreamOrder: 'asc' },
select: { accountId: true, type: true, name: true, openedOn: true },
});
const next = supported.map(({ id, ...account }) => ({
accountId: id,
...account,
}));
const changed =
previous.length !== next.length ||
previous.some((account, index) => {
const incoming = next[index];
return (
account.accountId !== incoming.accountId ||
account.type !== incoming.type ||
account.name !== incoming.name ||
account.openedOn !== incoming.openedOn
);
});
await tx.tbankAccount.deleteMany({
where: {
userId,
revision,
accountId: { notIn: supported.map(({ id }) => id) },
},
});
for (const [upstreamOrder, account] of next.entries()) {
await tx.tbankAccount.upsert({
where: {
userId_revision_accountId: {
userId,
revision,
accountId: account.accountId,
},
},
create: { userId, revision, ...account, upstreamOrder },
update: { ...account, upstreamOrder },
});
}
await tx.tbankConnection.update({
where: { userId_revision: { userId, revision } },
data: {
membershipAt: fetchedAt,
membershipRevision: { increment: changed ? 1 : 0 },
},
});
if (completeWork)
await tx.tbankSyncWork.update({
where: { id: attempt.id },
data: { state: 'complete', progress: { accounts: supported.length } },
});
return true;
});
}
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
});
if (!claimed.count) return false;
await tx.tbankConnection.update({
where: {
userId_revision: {
userId: attempt.userId,
revision: attempt.revision,
},
},
data: { invalidToken: true },
});
return true;
});
}
// This boundary neither decrypts credentials nor starts discovery/sync.
// Missing membership is distinct from a successfully observed empty list.
async local(userId: string, accountId?: string) {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: {
userId: true,
revision: true,
invalidToken: true,
membershipAt: true,
membershipRevision: true,
accounts: { orderBy: { upstreamOrder: 'asc' } },
},
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken)
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
if (
connection.membershipAt &&
accountId &&
accountId !== 'all' &&
!connection.accounts.some((account) => account.accountId === accountId)
)
throw new NotFoundException();
return connection;
}
async listSupportedAccounts(
userId: string,
): Promise<SupportedTbankAccount[]> {
@@ -97,6 +319,8 @@ export class TbankAccessService {
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken)
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
return {
token: this.tokens.decrypt(connection.encryptedToken),
generation: createHash('sha256')