feat: add guarded savings agent tools
This commit is contained in:
34
convex/bank/balanceProviders.test.ts
Normal file
34
convex/bank/balanceProviders.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mapComdirectBalances } from "./comdirectProvider";
|
||||
|
||||
describe("mapComdirectBalances", () => {
|
||||
test("normalizes comdirect balance payloads into provider balances", () => {
|
||||
const result = mapComdirectBalances({
|
||||
values: [
|
||||
{
|
||||
account: {
|
||||
accountId: "account-1",
|
||||
iban: "DE89370400440532013000",
|
||||
accountType: { text: "Girokonto" },
|
||||
},
|
||||
balance: { value: "1234.56", unit: "EUR" },
|
||||
date: "2026-06-23",
|
||||
},
|
||||
{
|
||||
account: {},
|
||||
balance: { value: "999" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
externalId: "account-1",
|
||||
balance: 1234.56,
|
||||
currency: "EUR",
|
||||
asOf: "2026-06-23",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
131
convex/bank/balances.ts
Normal file
131
convex/bank/balances.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { action, query } from "../_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "../_generated/api";
|
||||
import { requireUserId } from "../lib/helpers";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
|
||||
const balanceProvider = v.union(v.literal("comdirect"), v.literal("fints"));
|
||||
const balanceStatus = v.union(
|
||||
v.literal("fresh"),
|
||||
v.literal("stale"),
|
||||
v.literal("error"),
|
||||
v.literal("missing"),
|
||||
);
|
||||
|
||||
export const listLatest = query({
|
||||
args: {
|
||||
includeArchived: v.optional(v.boolean()),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
accountId: v.id("accounts"),
|
||||
accountName: v.string(),
|
||||
accountType: v.string(),
|
||||
iban: v.optional(v.string()),
|
||||
externalId: v.optional(v.string()),
|
||||
balance: v.union(v.number(), v.null()),
|
||||
currency: v.string(),
|
||||
provider: v.union(balanceProvider, v.null()),
|
||||
fetchedAt: v.union(v.number(), v.null()),
|
||||
asOf: v.optional(v.string()),
|
||||
status: balanceStatus,
|
||||
errorMessage: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const accounts = await ctx.db
|
||||
.query("accounts")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
const visibleAccounts = accounts
|
||||
.filter((account) => args.includeArchived || !account.isArchived)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const balances = await ctx.db
|
||||
.query("accountBalances")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
const latestByAccount = new Map<Id<"accounts">, Doc<"accountBalances">>();
|
||||
for (const balance of balances.sort((a, b) => b.fetchedAt - a.fetchedAt)) {
|
||||
if (!latestByAccount.has(balance.accountId)) {
|
||||
latestByAccount.set(balance.accountId, balance);
|
||||
}
|
||||
}
|
||||
|
||||
return visibleAccounts.map((account) => {
|
||||
const latest = latestByAccount.get(account._id);
|
||||
if (!latest) {
|
||||
return {
|
||||
accountId: account._id,
|
||||
accountName: account.name,
|
||||
accountType: account.type,
|
||||
iban: account.iban,
|
||||
externalId: account.externalId,
|
||||
balance: null,
|
||||
currency: account.currency,
|
||||
provider: null,
|
||||
fetchedAt: null,
|
||||
status: "missing" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: account._id,
|
||||
accountName: account.name,
|
||||
accountType: account.type,
|
||||
iban: account.iban,
|
||||
externalId: account.externalId ?? latest.externalId,
|
||||
balance: latest.balance,
|
||||
currency: latest.currency,
|
||||
provider: latest.provider,
|
||||
fetchedAt: latest.fetchedAt,
|
||||
asOf: latest.asOf,
|
||||
status: latest.status,
|
||||
errorMessage: latest.errorMessage,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const refresh = action({
|
||||
args: {
|
||||
accountId: v.optional(v.id("accounts")),
|
||||
pin: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
updatedCount: v.number(),
|
||||
provider: balanceProvider,
|
||||
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,
|
||||
): Promise<{
|
||||
updatedCount: number;
|
||||
provider: "comdirect" | "fints";
|
||||
awaitingTan: boolean;
|
||||
errors: Array<{
|
||||
accountId?: Id<"accounts">;
|
||||
externalId?: string;
|
||||
message: string;
|
||||
}>;
|
||||
}> => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Nicht angemeldet");
|
||||
|
||||
return await ctx.runAction(internal.bank.orchestrator.refreshBalancesInternal, {
|
||||
userId,
|
||||
accountId: args.accountId,
|
||||
pin: args.pin,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -33,6 +33,77 @@ type ComdirectProviderContext = {
|
||||
userId: Id<"users">;
|
||||
};
|
||||
|
||||
type ComdirectBalancePayload = {
|
||||
values?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function getNestedText(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
const text = (value as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : undefined;
|
||||
}
|
||||
|
||||
export function mapComdirectBalances(
|
||||
payload: ComdirectBalancePayload,
|
||||
): NormalizedBalance[] {
|
||||
return (payload.values ?? []).flatMap((item) => {
|
||||
const account = item.account as
|
||||
| {
|
||||
accountId?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const balance = item.balance as
|
||||
| {
|
||||
value?: unknown;
|
||||
unit?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const externalId =
|
||||
typeof account?.accountId === "string" ? account.accountId : undefined;
|
||||
if (!externalId) return [];
|
||||
|
||||
const balanceValue = Number(balance?.value ?? 0);
|
||||
const currency = typeof balance?.unit === "string" ? balance.unit : "EUR";
|
||||
const asOf = typeof item.date === "string" ? item.date : undefined;
|
||||
return [
|
||||
{
|
||||
externalId,
|
||||
balance: Number.isFinite(balanceValue) ? balanceValue : 0,
|
||||
currency,
|
||||
asOf,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function mapComdirectAccounts(
|
||||
payload: ComdirectBalancePayload,
|
||||
): NormalizedAccount[] {
|
||||
return (payload.values ?? []).flatMap((item) => {
|
||||
const account = item.account as
|
||||
| {
|
||||
accountId?: unknown;
|
||||
iban?: unknown;
|
||||
accountType?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const accountIdExternal =
|
||||
typeof account?.accountId === "string" ? account.accountId : undefined;
|
||||
if (!accountIdExternal) return [];
|
||||
|
||||
const balance = mapComdirectBalances({ values: [item] })[0];
|
||||
return [
|
||||
{
|
||||
externalId: accountIdExternal,
|
||||
name: getNestedText(account?.accountType) ?? "comdirect Konto",
|
||||
iban: typeof account?.iban === "string" ? account.iban : undefined,
|
||||
balance: balance?.balance ?? 0,
|
||||
currency: balance?.currency ?? "EUR",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export async function createComdirectRestProvider(
|
||||
context: ComdirectProviderContext,
|
||||
): Promise<BankDataProvider> {
|
||||
@@ -57,39 +128,16 @@ export async function createComdirectRestProvider(
|
||||
|
||||
async getAccounts(): Promise<NormalizedAccount[]> {
|
||||
const balances = await getAccountBalances(accessToken, sessionUuid);
|
||||
return (balances.values ?? []).flatMap((item) => {
|
||||
const account = item.account as {
|
||||
accountId?: string;
|
||||
iban?: string;
|
||||
accountType?: { text?: string };
|
||||
};
|
||||
const accountIdExternal = account?.accountId;
|
||||
if (!accountIdExternal) return [];
|
||||
const balanceValue = Number((item.balance as { value?: string })?.value ?? 0);
|
||||
return [
|
||||
{
|
||||
externalId: accountIdExternal,
|
||||
name: account.accountType?.text ?? "comdirect Konto",
|
||||
iban: account.iban,
|
||||
balance: balanceValue,
|
||||
currency: "EUR",
|
||||
},
|
||||
];
|
||||
});
|
||||
return mapComdirectAccounts(balances);
|
||||
},
|
||||
|
||||
async getBalance(accountExternalId: string): Promise<NormalizedBalance> {
|
||||
const balances = await getAccountBalances(accessToken, sessionUuid);
|
||||
const match = (balances.values ?? []).find((item) => {
|
||||
const account = item.account as { accountId?: string };
|
||||
return account?.accountId === accountExternalId;
|
||||
});
|
||||
const match = mapComdirectBalances(balances).find(
|
||||
(item) => item.externalId === accountExternalId,
|
||||
);
|
||||
if (!match) throw new Error(`Konto ${accountExternalId} nicht gefunden`);
|
||||
return {
|
||||
externalId: accountExternalId,
|
||||
balance: Number((match.balance as { value?: string })?.value ?? 0),
|
||||
currency: "EUR",
|
||||
};
|
||||
return match;
|
||||
},
|
||||
|
||||
async getTransactions(
|
||||
@@ -100,7 +148,7 @@ export async function createComdirectRestProvider(
|
||||
const rows: NormalizedTransaction[] = [];
|
||||
for (const state of ["BOOKED", "NOTBOOKED"] as const) {
|
||||
let offset = 0;
|
||||
let matches = 0;
|
||||
let matches: number;
|
||||
do {
|
||||
const result = await getTransactions(accessToken, sessionUuid, accountExternalId, {
|
||||
transactionState: state,
|
||||
@@ -207,3 +255,53 @@ export async function fetchComdirectData(
|
||||
|
||||
return { accounts, transactionsByAccount };
|
||||
}
|
||||
|
||||
export async function fetchComdirectBalanceData(
|
||||
ctx: ActionCtx,
|
||||
userId: Id<"users">,
|
||||
filterAccountId: Id<"accounts"> | undefined,
|
||||
): Promise<{
|
||||
accounts: NormalizedAccount[];
|
||||
balances: NormalizedBalance[];
|
||||
}> {
|
||||
const clientId = process.env.COMDIRECT_CLIENT_ID;
|
||||
const clientSecret = process.env.COMDIRECT_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error("comdirect API-Zugangsdaten nicht konfiguriert");
|
||||
}
|
||||
|
||||
const session = await ctx.runQuery(internal.comdirect.internal.getSession, { userId });
|
||||
if (!session?.accessToken || !session.secondaryActive) {
|
||||
throw new Error("comdirect-Session nicht aktiv. Bitte erneut anmelden.");
|
||||
}
|
||||
|
||||
const payload = await getAccountBalances(session.accessToken, session.sessionUuid);
|
||||
const accounts = mapComdirectAccounts(payload);
|
||||
const balances = mapComdirectBalances(payload);
|
||||
|
||||
if (!filterAccountId) {
|
||||
return { accounts, balances };
|
||||
}
|
||||
|
||||
const filteredAccounts: NormalizedAccount[] = [];
|
||||
const allowedExternalIds = new Set<string>();
|
||||
for (const account of accounts) {
|
||||
const convexId = await ctx.runMutation(internal.bank.internal.upsertAccountFromProvider, {
|
||||
userId,
|
||||
externalId: account.externalId,
|
||||
name: account.name,
|
||||
iban: account.iban,
|
||||
balance: account.balance,
|
||||
currency: account.currency,
|
||||
});
|
||||
if (convexId === filterAccountId) {
|
||||
filteredAccounts.push(account);
|
||||
allowedExternalIds.add(account.externalId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accounts: filteredAccounts,
|
||||
balances: balances.filter((balance) => allowedExternalIds.has(balance.externalId)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +56,27 @@ const pendingTanValidator = v.object({
|
||||
submittedTan: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const accountBalanceStatus = v.union(
|
||||
v.literal("fresh"),
|
||||
v.literal("stale"),
|
||||
v.literal("error"),
|
||||
);
|
||||
|
||||
const accountBalanceValidator = v.object({
|
||||
_id: v.id("accountBalances"),
|
||||
_creationTime: v.number(),
|
||||
userId: v.id("users"),
|
||||
accountId: v.id("accounts"),
|
||||
externalId: v.string(),
|
||||
provider: v.union(v.literal("comdirect"), v.literal("fints")),
|
||||
balance: v.number(),
|
||||
currency: v.string(),
|
||||
asOf: v.optional(v.string()),
|
||||
fetchedAt: v.number(),
|
||||
status: accountBalanceStatus,
|
||||
errorMessage: v.optional(v.string()),
|
||||
});
|
||||
|
||||
export const getBankConfig = internalQuery({
|
||||
args: { userId: v.id("users") },
|
||||
returns: v.union(bankConfigValidator, v.null()),
|
||||
@@ -89,6 +110,33 @@ export const getPendingTan = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const listLatestAccountBalances = internalQuery({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
includeArchived: v.optional(v.boolean()),
|
||||
},
|
||||
returns: v.array(accountBalanceValidator),
|
||||
handler: async (ctx, args) => {
|
||||
const accounts = await ctx.db
|
||||
.query("accounts")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.collect();
|
||||
const visibleAccountIds = new Set(
|
||||
accounts
|
||||
.filter((account) => args.includeArchived || !account.isArchived)
|
||||
.map((account) => account._id),
|
||||
);
|
||||
const balances = await ctx.db
|
||||
.query("accountBalances")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.collect();
|
||||
|
||||
return balances
|
||||
.filter((balance) => visibleAccountIds.has(balance.accountId))
|
||||
.sort((a, b) => b.fetchedAt - a.fetchedAt);
|
||||
},
|
||||
});
|
||||
|
||||
export const upsertBankConfig = internalMutation({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
@@ -286,3 +334,52 @@ export const upsertAccountFromProvider = internalMutation({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const upsertAccountBalance = internalMutation({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
accountId: v.id("accounts"),
|
||||
externalId: v.string(),
|
||||
provider: v.union(v.literal("comdirect"), v.literal("fints")),
|
||||
balance: v.number(),
|
||||
currency: v.string(),
|
||||
asOf: v.optional(v.string()),
|
||||
fetchedAt: v.number(),
|
||||
status: accountBalanceStatus,
|
||||
errorMessage: v.optional(v.string()),
|
||||
},
|
||||
returns: v.id("accountBalances"),
|
||||
handler: async (ctx, args) => {
|
||||
const account = await ctx.db.get(args.accountId);
|
||||
if (!account || account.userId !== args.userId) {
|
||||
throw new Error("Konto nicht gefunden");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("accountBalances")
|
||||
.withIndex("by_user_account", (q) =>
|
||||
q.eq("userId", args.userId).eq("accountId", args.accountId),
|
||||
)
|
||||
.unique();
|
||||
|
||||
const fields = {
|
||||
userId: args.userId,
|
||||
accountId: args.accountId,
|
||||
externalId: args.externalId,
|
||||
provider: args.provider,
|
||||
balance: args.balance,
|
||||
currency: args.currency,
|
||||
asOf: args.asOf,
|
||||
fetchedAt: args.fetchedAt,
|
||||
status: args.status,
|
||||
errorMessage: args.errorMessage,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, fields);
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
return await ctx.db.insert("accountBalances", fields);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -14,6 +14,7 @@ export type NormalizedBalance = {
|
||||
externalId: string;
|
||||
balance: number;
|
||||
currency: string;
|
||||
asOf?: string;
|
||||
};
|
||||
|
||||
export type NormalizedTransaction = {
|
||||
|
||||
Reference in New Issue
Block a user