feat: add guarded savings agent tools

This commit is contained in:
Matthias
2026-06-23 21:13:02 +02:00
parent 6e687dc9b5
commit b7dae31fe1
22 changed files with 3510 additions and 73 deletions

View File

@@ -25,11 +25,17 @@ import {
} from "./fintsSession";
import { mapFinTsTransaction } from "./fintsMap";
import {
fetchComdirectBalanceData,
fetchComdirectData,
hasComdirectCredentials,
isRestFallbackError,
} from "./comdirectProvider";
import type { ImportRow, NormalizedAccount, NormalizedTransaction } from "./types";
import type {
ImportRow,
NormalizedAccount,
NormalizedBalance,
NormalizedTransaction,
} from "./types";
export const TAN_POLL_INTERVAL_MS = 4000;
export const TAN_TIMEOUT_MS = 5 * 60 * 1000;
@@ -96,6 +102,12 @@ type PendingSyncJob = {
partialTransactions?: Record<string, NormalizedTransaction[]>;
};
type BalanceRefreshError = {
accountId?: Id<"accounts">;
externalId?: string;
message: string;
};
async function waitForDecoupledTan(
ctx: ActionCtx,
userId: Id<"users">,
@@ -368,6 +380,34 @@ async function fetchStatementsAllPages(
return resp;
}
async function fetchFinTsAccountBalance(
ctx: ActionCtx,
userId: Id<"users">,
client: FinTSClient,
account: NormalizedAccount,
syncJob: PendingSyncJob,
): Promise<NormalizedBalance> {
if (!client.canGetAccountBalance(account.externalId)) {
throw new Error("Konto unterstützt keinen HKSAL-Kontostandabruf");
}
let balanceResponse = await client.getAccountBalance(account.externalId);
balanceResponse = await resolveTanResponse(
ctx,
userId,
client,
balanceResponse,
"balance",
syncJob,
);
return {
externalId: account.externalId,
balance: balanceResponse.balance?.balance ?? account.balance,
currency: balanceResponse.balance?.currency ?? account.currency,
};
}
async function fetchFinTsAccountData(
client: FinTSClient,
account: NormalizedAccount,
@@ -408,16 +448,14 @@ async function fetchFinTsAccountData(
let balance = account.balance;
if (canFetchBalance) {
let balanceResponse = await client.getAccountBalance(account.externalId);
balanceResponse = await resolveTanResponse(
const balanceResult = await fetchFinTsAccountBalance(
ctx,
userId,
client,
balanceResponse,
"balance",
account,
syncJob,
);
balance = balanceResponse.balance?.balance ?? account.balance;
balance = balanceResult.balance;
} else {
console.warn("[fints] Konto unterstützt keinen HKSAL-Kontostandabruf", {
account: account.externalId,
@@ -639,6 +677,156 @@ async function fetchFinTsData(
return { accounts, transactionsByAccount };
}
async function fetchFinTsBalanceData(
ctx: ActionCtx,
userId: Id<"users">,
filterAccountId: Id<"accounts"> | undefined,
pin: string | undefined,
): Promise<{
accounts: NormalizedAccount[];
balances: NormalizedBalance[];
errors: BalanceRefreshError[];
}> {
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, { userId });
const env = resolveFintsEnv({
blz: bankConfig?.fints.blz,
url: bankConfig?.fints.url,
login: bankConfig?.fints.login,
productId: bankConfig?.fints.productId,
productVersion: bankConfig?.fints.productVersion,
tanMethodId: bankConfig?.fints.tanMethodId,
tanMediaName: bankConfig?.fints.tanMediaName,
bankingInformationJson: bankConfig?.fints.bankingInformationJson,
pin,
});
const today = new Date().toISOString().slice(0, 10);
const syncJob: PendingSyncJob = {
from: today,
to: today,
accountId: filterAccountId,
provider: "fints",
phase: "fetch",
};
let client = createFinTsClient(env);
client = await ensureFinTsReady(client, env, ctx, userId, syncJob);
const bankAccounts = client.config.bankingInformation.upd?.bankAccounts ?? [];
const accounts = bankAccounts.map(mapBankAccount);
const balances: NormalizedBalance[] = [];
const errors: BalanceRefreshError[] = [];
for (const account of accounts) {
try {
const balance = await fetchFinTsAccountBalance(
ctx,
userId,
client,
account,
syncJob,
);
account.balance = balance.balance;
balances.push(balance);
} catch (error) {
errors.push({
externalId: account.externalId,
message: error instanceof Error ? error.message : "Kontostandabruf fehlgeschlagen",
});
}
}
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId,
fints: {
blz: env.blz,
url: env.url,
login: env.login,
productId: env.productId,
bankingInformationJson: JSON.stringify(client.config.bankingInformation),
tanMethodId: client.config.selectedTanMethod?.id,
tanMediaName: methodMediaName(client),
},
});
return { accounts, balances, errors };
}
async function persistBalanceResults(
ctx: ActionCtx,
userId: Id<"users">,
provider: "comdirect" | "fints",
filterAccountId: Id<"accounts"> | undefined,
accounts: NormalizedAccount[],
balances: NormalizedBalance[],
errors: BalanceRefreshError[],
): Promise<{ updatedCount: number; errors: BalanceRefreshError[] }> {
const fetchedAt = Date.now();
const accountIdMap = new Map<string, Id<"accounts">>();
const accountByExternalId = new Map(accounts.map((account) => [account.externalId, account]));
for (const account of accounts) {
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId,
externalId: account.externalId,
name: account.name,
iban: account.iban,
balance: account.balance,
currency: account.currency,
});
accountIdMap.set(account.externalId, convexAccountId);
}
let updatedCount = 0;
for (const balance of balances) {
const accountId = accountIdMap.get(balance.externalId);
if (!accountId || (filterAccountId && accountId !== filterAccountId)) continue;
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
userId,
accountId,
externalId: balance.externalId,
provider,
balance: balance.balance,
currency: balance.currency,
asOf: balance.asOf,
fetchedAt,
status: "fresh",
});
updatedCount += 1;
}
const scopedErrors: BalanceRefreshError[] = [];
for (const error of errors) {
const externalId = error.externalId;
const accountId = externalId ? accountIdMap.get(externalId) : error.accountId;
if (filterAccountId && accountId !== filterAccountId) continue;
scopedErrors.push({ ...error, accountId });
const account = externalId ? accountByExternalId.get(externalId) : undefined;
if (accountId && account) {
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
userId,
accountId,
externalId: account.externalId,
provider,
balance: account.balance,
currency: account.currency,
fetchedAt,
status: "error",
errorMessage: error.message,
});
}
}
await ctx.runMutation(internal.bank.internal.updateSyncState, {
userId,
lastSync: updatedCount > 0 ? fetchedAt : undefined,
lastProviderUsed: provider,
lastError: scopedErrors[0]?.message ?? null,
});
return { updatedCount, errors: scopedErrors };
}
async function persistSyncResults(
ctx: ActionCtx,
userId: Id<"users">,
@@ -650,6 +838,7 @@ async function persistSyncResults(
transactionsByAccount: Map<string, NormalizedTransaction[]>,
): Promise<{ importedCount: number; skippedCount: number }> {
const accountIdMap = new Map<string, Id<"accounts">>();
const fetchedAt = Date.now();
for (const account of accounts) {
const convexAccountId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
userId,
@@ -660,6 +849,16 @@ async function persistSyncResults(
currency: account.currency,
});
accountIdMap.set(account.externalId, convexAccountId);
await ctx.runMutation(internal.bank.internal.upsertAccountBalance, {
userId,
accountId: convexAccountId,
externalId: account.externalId,
provider,
balance: account.balance,
currency: account.currency,
fetchedAt,
status: "fresh",
});
}
const rows: ImportRow[] = [];
@@ -850,6 +1049,100 @@ export const runSyncInternal = internalAction({
},
});
export const refreshBalancesInternal = internalAction({
args: {
userId: v.id("users"),
accountId: v.optional(v.id("accounts")),
pin: v.optional(v.string()),
},
returns: v.object({
updatedCount: v.number(),
provider: v.union(v.literal("comdirect"), v.literal("fints")),
awaitingTan: v.boolean(),
errors: v.array(
v.object({
accountId: v.optional(v.id("accounts")),
externalId: v.optional(v.string()),
message: v.string(),
}),
),
}),
handler: async (ctx, args) => {
const comdirectReady = hasComdirectCredentials();
await ctx.runMutation(internal.bank.internal.upsertBankConfig, {
userId: args.userId,
comdirectHasCredentials: comdirectReady,
});
const bankConfig = await ctx.runQuery(internal.bank.internal.getBankConfig, {
userId: args.userId,
});
const preference = bankConfig?.providerPreference ?? "auto";
const useFinTsDirect =
preference === "fints" || (preference === "auto" && !comdirectReady);
const tryFinTs = async (reason: string) => {
await logProvider(ctx, args.userId, "fints", reason);
const { accounts, balances, errors } = await fetchFinTsBalanceData(
ctx,
args.userId,
args.accountId,
args.pin,
);
const result = await persistBalanceResults(
ctx,
args.userId,
"fints",
args.accountId,
accounts,
balances,
errors,
);
return {
...result,
provider: "fints" as const,
awaitingTan: false,
};
};
if (useFinTsDirect) {
return await tryFinTs(
!comdirectReady
? "comdirect-Credentials fehlen"
: "Provider-Präferenz FinTS",
);
}
try {
await logProvider(ctx, args.userId, "comdirect", "REST-Saldenabruf");
const { accounts, balances } = await fetchComdirectBalanceData(
ctx,
args.userId,
args.accountId,
);
const result = await persistBalanceResults(
ctx,
args.userId,
"comdirect",
args.accountId,
accounts,
balances,
[],
);
return {
...result,
provider: "comdirect" as const,
awaitingTan: false,
};
} catch (error) {
if (!isRestFallbackError(error)) throw error;
const reason = error instanceof Error ? error.message : "REST-Fehler";
console.warn("[bank-balance] REST fehlgeschlagen, Fallback FinTS:", reason);
return await tryFinTs(`REST-Fallback: ${reason}`);
}
},
});
export const pollTan = internalAction({
args: {
userId: v.id("users"),