From 39a0f8fca70755c5b5cfff974b984015dcdaecec Mon Sep 17 00:00:00 2001 From: suiramdev Date: Fri, 13 Nov 2026 14:36:20 +0100 Subject: [PATCH] feat(api): sync a new bank connection as soon as it is linked --- apps/fumadocs/content/docs/next/index.mdx | 2 +- .../docs/next/reference/procedures.mdx | 4 +- .../docs/next/use/bank-connections.mdx | 7 +-- apps/web/src/app/shell/use-account-sync.ts | 9 ++- packages/api/src/routers/bank-connection.ts | 25 +++++++- packages/api/src/routers/budget.ts | 60 ++++++++++--------- 6 files changed, 71 insertions(+), 36 deletions(-) diff --git a/apps/fumadocs/content/docs/next/index.mdx b/apps/fumadocs/content/docs/next/index.mdx index 13a695b8..cf80e1fd 100644 --- a/apps/fumadocs/content/docs/next/index.mdx +++ b/apps/fumadocs/content/docs/next/index.mdx @@ -8,7 +8,7 @@ Freenary is an open-source personal finance app you run on your own server. It l ## What it does today -- **Bank connections.** Link your banks through a bank provider, Powens or Enable Banking. Freenary fetches new transactions when you sign in or load a page, and when you press **Sync transactions**. +- **Bank connections.** Link your banks through a bank provider, Powens or Enable Banking. Freenary fetches new transactions as soon as you connect a bank, when you sign in or load a page, and when you press **Sync transactions**. - **Budget.** **Transactions** shows what came in and went out over a period, by category. **Recurring** lists subscriptions, rent and other payments that repeat. - **Categories.** Freenary sorts transactions into built-in categories, and you can correct each one. Your own categories are for budget lines. - **A monthly plan.** Set an amount per category in **Settings**, then compare it with what you spent. diff --git a/apps/fumadocs/content/docs/next/reference/procedures.mdx b/apps/fumadocs/content/docs/next/reference/procedures.mdx index 4c32b4b6..b7763e4d 100644 --- a/apps/fumadocs/content/docs/next/reference/procedures.mdx +++ b/apps/fumadocs/content/docs/next/reference/procedures.mdx @@ -139,7 +139,9 @@ A caller with no session gets a `guest` answer, not a 401. **Input**: `params` is the raw callback query and must hold a `state` key. It takes at most 20 keys, each key at most 100 characters and each value at most 4096. `providerId` is `powens`, `enable-banking` or, outside production, `mock`. -**Output**: `{ accounts, connectionId, institutionName, returnTo, sessionId }`. `accounts` lists `{ iban?, name?, uid }` as the provider returned them. `returnTo` is `onboarding` or `settings`, as signed into the state. `sessionId` is the provider's session id. +**Output**: `{ accounts, connectionId, institutionName, returnTo, sessionId, syncStartedAt }`. `accounts` lists `{ iban?, name?, uid }` as the provider returned them. `returnTo` is `onboarding` or `settings`, as signed into the state. `sessionId` is the provider's session id. + +The procedure starts the new connection's first sync before it returns, as `budget.syncAccounts` with this `connectionId` would. `syncStartedAt` is the date to pass to `budget.getSyncOutcome`, or `null` when none started, usually because another sync was already running. **Errors** diff --git a/apps/fumadocs/content/docs/next/use/bank-connections.mdx b/apps/fumadocs/content/docs/next/use/bank-connections.mdx index 825e7c75..30e10f11 100644 --- a/apps/fumadocs/content/docs/next/use/bank-connections.mdx +++ b/apps/fumadocs/content/docs/next/use/bank-connections.mdx @@ -48,9 +48,7 @@ If you cancel at your bank, Freenary says `Bank connection cancelled. Nothing wa ## What arrives, and when -Right after you connect, the bank and its accounts are in the list, marked `Not synced yet`. No transaction has arrived yet. - -Transactions arrive with the first sync, which starts on its own once you're back in Freenary: in Settings, or when you finish onboarding. The re-sync button on the bank's row starts one too. The first sync reads the last **90 days**. Anything older on the day you connect never arrives. +Right after you connect, the bank and its accounts are in the list, marked `Not synced yet`, and its first sync has already started, so Home and Budget fill in without waiting for you to open them. If another sync was already running when you connected, the new bank waits for the next one; the re-sync button on its row starts it now. The first sync reads the last **90 days**. Anything older on the day you connect never arrives. While a sync runs, a message in the corner of the screen says what it's doing and how far it got, on every page once you're signed in. It goes away when the sync ends. Budget fills in as transactions arrive. Freenary then sorts them into categories; [Budget](/docs/use/budget#fix-a-category) shows how to fix the ones it gets wrong. @@ -60,7 +58,8 @@ A sync fetches transactions from your banks. Freenary has no nightly sync: it sy | You | Freenary | | --- | --- | -| Sign in, finish onboarding, or load or reload a page | Runs a quiet sync of every bank. It fetches what's new since the last sync and sorts only the new transactions. Moving between pages doesn't start another one | +| Connect a bank | Syncs that bank right away, reading the last 90 days | +| Sign in, finish onboarding, or load or reload a page | Runs a quiet sync of every bank, unless a sync is already running. It fetches what's new since the last sync and sorts only the new transactions. Moving between pages doesn't start another one | | Press `Sync transactions` on Budget | Reads the last 90 days again from every bank, and sorts all your transactions into categories again | | Press the re-sync button on a bank's row in Settings | Does the same for that one bank | diff --git a/apps/web/src/app/shell/use-account-sync.ts b/apps/web/src/app/shell/use-account-sync.ts index 321c70bb..93301a73 100644 --- a/apps/web/src/app/shell/use-account-sync.ts +++ b/apps/web/src/app/shell/use-account-sync.ts @@ -61,13 +61,20 @@ export const useAccountSync = () => { const hasStartedBackgroundSync = useRef(false); const isIdle = statusQuery.isSuccess && progress === null; + const isAlreadySyncing = statusQuery.isSuccess && progress !== null; useEffect(() => { + if (isAlreadySyncing) { + hasStartedBackgroundSync.current = true; + + return; + } + if (hasActiveConnection && isIdle && !hasStartedBackgroundSync.current) { hasStartedBackgroundSync.current = true; sync({}); } - }, [hasActiveConnection, isIdle, sync]); + }, [hasActiveConnection, isAlreadySyncing, isIdle, sync]); return progress; }; diff --git a/packages/api/src/routers/bank-connection.ts b/packages/api/src/routers/bank-connection.ts index 5af9f50a..509a9643 100644 --- a/packages/api/src/routers/bank-connection.ts +++ b/packages/api/src/routers/bank-connection.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import prisma from "@freenary/db"; import { env } from "@freenary/env/server"; import { ORPCError } from "@orpc/server"; -import { Effect, Option } from "effect"; +import { Data, Effect, Option } from "effect"; import { z } from "zod"; import { protectedProcedure } from "../index"; @@ -26,6 +26,11 @@ import { } from "../lib/country-code"; import { getDefaultProvider, getProvider } from "../providers/registry"; import type { BankingProvider, ProviderInstitution } from "../providers/types"; +import { startAccountSync } from "./budget"; + +class FirstSyncNotStarted extends Data.TaggedError("FirstSyncNotStarted")<{ + readonly cause: unknown; +}> {} const providerById = Option.liftThrowable(getProvider); @@ -283,6 +288,23 @@ export const bankConnectionRouter = { return created; }); + const syncStartedAt = await Effect.runPromise( + Effect.tryPromise({ + catch: (cause) => new FirstSyncNotStarted({ cause }), + try: () => + startAccountSync(userId, { + connectionId: connection.id, + force: false, + }), + }).pipe( + Effect.catchTag("FirstSyncNotStarted", (failure) => + Effect.logWarning("First bank sync not started", failure).pipe( + Effect.as(null) + ) + ) + ) + ); + return { accounts: completed.accounts.map((a) => ({ iban: a.iban, @@ -293,6 +315,7 @@ export const bankConnectionRouter = { institutionName: bankName, returnTo: connectionState.returnTo, sessionId: completed.providerSessionId, + syncStartedAt, }; }), diff --git a/packages/api/src/routers/budget.ts b/packages/api/src/routers/budget.ts index d24a030b..da5370ef 100644 --- a/packages/api/src/routers/budget.ts +++ b/packages/api/src/routers/budget.ts @@ -822,6 +822,32 @@ const runSyncAccounts = Effect.fnUntraced(function* runSyncAccounts( }; }); +export const startAccountSync = async ( + userId: string, + { connectionId, force }: { connectionId?: string; force: boolean } +): Promise => { + const claimedAt = await claimSyncRun(userId, "IMPORTING"); + + if (claimedAt === null) { + return null; + } + + const reporter = createSyncReporter(userId, "IMPORTING", claimedAt); + + Effect.runFork( + runSyncAccounts(userId, connectionId, force, reporter).pipe( + Effect.tapCause(Effect.logError), + Effect.matchCauseEffect({ + onFailure: () => Effect.promise(() => reporter.finished()), + onSuccess: (outcome) => + Effect.promise(() => reporter.finishedWith(outcome)), + }) + ) + ); + + return claimedAt; +}; + const sankeyOf = ( transactions: PeriodRow[], aggregation: AggregationMode, @@ -1452,34 +1478,12 @@ export const budgetRouter = { }) .optional() ) - .handler(async ({ context, input }) => { - const userId = context.session.user.id; - const claimedAt = await claimSyncRun(userId, "IMPORTING"); - - if (claimedAt === null) { - return { startedAt: null }; - } - - const reporter = createSyncReporter(userId, "IMPORTING", claimedAt); - - Effect.runFork( - runSyncAccounts( - userId, - input?.connectionId, - input?.force ?? false, - reporter - ).pipe( - Effect.tapCause(Effect.logError), - Effect.matchCauseEffect({ - onFailure: () => Effect.promise(() => reporter.finished()), - onSuccess: (outcome) => - Effect.promise(() => reporter.finishedWith(outcome)), - }) - ) - ); - - return { startedAt: claimedAt }; - }), + .handler(async ({ context, input }) => ({ + startedAt: await startAccountSync(context.session.user.id, { + connectionId: input?.connectionId, + force: input?.force ?? false, + }), + })), updateTransactionCategory: protectedProcedure .input(