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
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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user