aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorivar <i@oiee.no>2025-12-15 00:40:21 +0100
committerivar <i@oiee.no>2025-12-15 00:40:21 +0100
commit2b5e4c4aa372632b87a404027cf53d5a7bfc3808 (patch)
tree4782fec17e6c3745fb43e209dfe80e33340d37c0
parent008862f8a2431c8f755a38a0ef242b8faf125057 (diff)
downloadsparebank1-actualbudget-2b5e4c4aa372632b87a404027cf53d5a7bfc3808.tar.xz
sparebank1-actualbudget-2b5e4c4aa372632b87a404027cf53d5a7bfc3808.zip
Progress
-rw-r--r--app/bun.lock7
-rw-r--r--app/bunfig.toml2
-rw-r--r--app/package.json5
-rw-r--r--app/src/lib/server/actual.ts21
-rw-r--r--app/src/lib/server/sb1.ts31
-rw-r--r--app/src/lib/ui/button.svelte1
-rw-r--r--app/src/routes/+page.server.ts14
-rw-r--r--app/src/routes/+page.svelte41
-rw-r--r--app/src/routes/actual.remote.ts4
-rw-r--r--app/src/routes/sb1.remote.ts16
10 files changed, 93 insertions, 49 deletions
diff --git a/app/bun.lock b/app/bun.lock
index a71dfd7..cceac74 100644
--- a/app/bun.lock
+++ b/app/bun.lock
@@ -4,6 +4,7 @@
"": {
"name": "app",
"dependencies": {
+ "@ivars/ueb": "latest",
"better-sqlite3": "latest",
},
"devDependencies": {
@@ -89,6 +90,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+ "@ivars/ueb": ["@ivars/ueb@0.1.0", "https://npm.ivar.systems/@ivars/ueb/-/ueb-0.1.0.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-4m9rRl4HgjmghCxu++1udLbpD/bkWnRlrbJ4mWx7NnT/kWDdARcX10+IBcPn2yLue3LQomAMw17dMufCPll0qA=="],
+
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -171,7 +174,7 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
- "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="],
+ "@types/node": ["@types/node@25.0.2", "https://npm.ivar.systems/@types/node/-/node-25.0.2.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@@ -379,7 +382,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
- "svelte": ["svelte@5.45.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-1Jh7FwVh/2Uxg0T7SeE1qFKMhwYH45b2v53bcZpW7qHa6O8iU1ByEj56PF0IQ6dU4HE5gRkic6h+vx+tclHeiw=="],
+ "svelte": ["svelte@5.46.0", "https://npm.ivar.systems/svelte/-/svelte-5.46.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw=="],
"svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="],
diff --git a/app/bunfig.toml b/app/bunfig.toml
new file mode 100644
index 0000000..7243f16
--- /dev/null
+++ b/app/bunfig.toml
@@ -0,0 +1,2 @@
+[install.scopes]
+"@ivars" = "https://npm.ivar.systems/"
diff --git a/app/package.json b/app/package.json
index 3def5e4..2cbd931 100644
--- a/app/package.json
+++ b/app/package.json
@@ -21,12 +21,12 @@
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/better-sqlite3": "^7.6.13",
- "@types/node": "^25.0.0",
+ "@types/node": "^25.0.2",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"lightningcss": "^1.30.2",
"phosphor-svelte": "^3.0.1",
- "svelte": "^5.45.8",
+ "svelte": "^5.46.0",
"svelte-check": "^4.3.4",
"temporal-polyfill": "^0.3.0",
"typescript": "^5.9.3",
@@ -34,6 +34,7 @@
"vite": "^7.2.7"
},
"dependencies": {
+ "@ivars/ueb": "^0.1.0",
"better-sqlite3": "^12.5.0"
}
} \ No newline at end of file
diff --git a/app/src/lib/server/actual.ts b/app/src/lib/server/actual.ts
new file mode 100644
index 0000000..7291aad
--- /dev/null
+++ b/app/src/lib/server/actual.ts
@@ -0,0 +1,21 @@
+import { ACTUAL_HOST, ACTUAL_PASS } from "$env/static/private";
+import * as actual from "@actual-app/api"
+import { existsSync, mkdirSync } from "node:fs";
+import path from "node:path";
+
+async function init_actual() {
+ const dataDir = path.resolve(import.meta.dirname, "actualDataDir");
+
+ if (!existsSync(dataDir)) mkdirSync(dataDir);
+
+ return actual.init({
+ password: ACTUAL_PASS,
+ serverURL: ACTUAL_HOST,
+ dataDir: dataDir
+ })
+}
+
+export async function get_budgets() {
+ await init_actual()
+ return await actual.getBudgets()
+} \ No newline at end of file
diff --git a/app/src/lib/server/sb1.ts b/app/src/lib/server/sb1.ts
index c060af5..1d6b869 100644
--- a/app/src/lib/server/sb1.ts
+++ b/app/src/lib/server/sb1.ts
@@ -24,12 +24,7 @@ type Transaction = {
const auth = {
async is_ready(): Promise<boolean> {
const token = await this.get_access_token()
- const ping = await fetch("https://developer.sparebank1.no/helloworld/ping", {
- headers: {
- "Authorization": "Bearer " + token
- }
- })
- return ping.ok
+ return token !== ""
},
async get_auth_info() {
const entity = await db.select({
@@ -39,7 +34,8 @@ const auth = {
}).from(syncSession)
if (!entity[0]) return undefined
const { tokens, accessTokenCreated, refreshTokenCreated } = entity[0]
- const tokensParsed = JSON.parse(tokens ?? "")
+ if (!tokens) return undefined
+ const tokensParsed = JSON.parse(tokens)
if (!tokensParsed) return undefined
const refreshTokenExpires = Temporal.Instant.fromEpochMilliseconds(Number(refreshTokenCreated)).add({ seconds: tokensParsed?.refresh_token_expires_in })
const accessTokenExpires = Temporal.Instant.fromEpochMilliseconds(Number(accessTokenCreated)).add({ seconds: tokensParsed?.expires_in })
@@ -56,22 +52,20 @@ const auth = {
})
const authorizeUrl = new URL("https://api.sparebank1.no/oauth/authorize");
-
authorizeUrl.searchParams.set("client_id", SB1_ID);
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("redirect_uri", SB1_REDIRECT_URI);
authorizeUrl.searchParams.set("finInst", SB1_FIN_INST);
authorizeUrl.searchParams.set("response_type", "code");
-
return authorizeUrl.toString()
},
async get_access_token() {
const entity = await db.select({
tokens: syncSession.tokens
}).from(syncSession)
- const { tokens } = entity[0]
- if (!tokens) return null
- const parsed = JSON.parse(tokens) as Sb1Tokens
+ const res = entity[0]
+ if (!res?.tokens) return null
+ const parsed = JSON.parse(res.tokens) as Sb1Tokens
return parsed.access_token as string
},
async refresh_tokem() {
@@ -127,15 +121,20 @@ const data = {
async get_transactions(accountKey: string) {
const token = await auth.get_access_token()
if (token) return undefined
- const url = new URL(
- "https://api.sparebank1.no/personal/banking/transactions",
- );
+ const url = new URL("https://api.sparebank1.no/personal/banking/transactions/transactions")
+ console.log(token)
url.searchParams.set("accountKey", accountKey);
- const response = await fetch(url, {
+ console.log(accountKey)
+ const response = await fetch("https://api.sparebank1.no/personal/banking/transactions" + new URLSearchParams({
+ "accountKey": accountKey,
+ "fromDate": "",
+ "toDate": ""
+ }), {
headers: {
Authorization: `Bearer ${token}`,
},
});
+ const json = await response.json()
return (await response.json())["transactions"] as Transaction[];
}
diff --git a/app/src/lib/ui/button.svelte b/app/src/lib/ui/button.svelte
index 313cd90..8c66bd2 100644
--- a/app/src/lib/ui/button.svelte
+++ b/app/src/lib/ui/button.svelte
@@ -26,6 +26,7 @@
display: flex;
gap: 3px;
transition: 0.1s all ease;
+ height: fit-content;
&:hover,
&:focus {
diff --git a/app/src/routes/+page.server.ts b/app/src/routes/+page.server.ts
new file mode 100644
index 0000000..0d6016b
--- /dev/null
+++ b/app/src/routes/+page.server.ts
@@ -0,0 +1,14 @@
+import type { PageServerLoad } from './$types';
+import { get_budgets } from '$lib/server/actual';
+import sb1 from "$lib/server/sb1"
+
+export const load = (async () => {
+ return {
+ actual: {
+ meta: await get_budgets()
+ },
+ sb1: {
+ accounts: (await sb1.data.get_accounts())?.accounts
+ }
+ };
+}) satisfies PageServerLoad; \ No newline at end of file
diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte
index e89df9b..26db3e4 100644
--- a/app/src/routes/+page.svelte
+++ b/app/src/routes/+page.svelte
@@ -1,10 +1,10 @@
<script lang="ts">
import Button from "$lib/ui/button.svelte";
- import { onMount } from "svelte";
- import { clear_auth_session, get_accounts, get_transactions, init_auth_session, is_ready } from "./sb1.remote";
- import { get_actual_meta } from "./actual.remote";
+ import { clear_auth_session, get_transactions, init_auth_session } from "./sb1.remote";
+ import type { PageProps } from "./$types";
let navigating = $state(false);
+ let { data }: PageProps = $props();
async function authorize() {
navigating = true;
@@ -16,23 +16,16 @@
async function logout() {
await clear_auth_session();
}
-
- onMount(async () => {
- await get_actual_meta();
- });
</script>
<main>
- {#if await is_ready()}
- {@const accounts = await get_accounts()}
- {@const actual_meta = await get_actual_meta()}
- {#if accounts}
- {#each accounts?.accounts as account}
- {@const transactions = await get_transactions(account.key)}
+ {#if data.sb1.accounts?.length}
+ <ul>
+ {#each data.sb1.accounts as account}
<li>{account.name}</li>
- {#if transactions?.length}
+ {#if (await get_transactions(account.key))?.length}
<ul>
- {#each transactions as transaction}
+ {#each await get_transactions(account.key) as transaction}
<li>{JSON.stringify(transaction)}</li>
{/each}
</ul>
@@ -40,12 +33,22 @@
<small>Ingen transaksjoner</small>
{/if}
{/each}
- {/if}
- {#if actual_meta}
- <pre>{JSON.stringify(actual_meta, null, 2)}</pre>
- {/if}
+ </ul>
<Button onclick={logout}>Logg ut</Button>
{:else}
<Button onclick={authorize} loading={navigating}>Autentisér hos Sparebanken 1</Button>
{/if}
+
+ {#if data.actual.meta}
+ <pre>{JSON.stringify(data.actual.meta, null, 2)}</pre>
+ {/if}
</main>
+
+<style>
+ main {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ height: 90vh;
+ }
+</style>
diff --git a/app/src/routes/actual.remote.ts b/app/src/routes/actual.remote.ts
index 4bd70b4..9560d4d 100644
--- a/app/src/routes/actual.remote.ts
+++ b/app/src/routes/actual.remote.ts
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
async function init_actual() {
- const dataDir = path.resolve(__dirname, "actualDataDir");
+ const dataDir = path.resolve(import.meta.dirname, "actualDataDir");
if (!existsSync(dataDir)) mkdirSync(dataDir);
@@ -19,4 +19,4 @@ async function init_actual() {
export const get_actual_meta = query(async () => {
await init_actual()
return await actual.getBudgets()
-}) \ No newline at end of file
+})
diff --git a/app/src/routes/sb1.remote.ts b/app/src/routes/sb1.remote.ts
index d2eb0cc..c3967c1 100644
--- a/app/src/routes/sb1.remote.ts
+++ b/app/src/routes/sb1.remote.ts
@@ -8,24 +8,24 @@ const init_auth_session = command(async () => {
return await sb1.auth.init_auth_session()
})
-const is_ready = query(async () => {
- return await sb1.auth.is_ready()
+const is_ready = query(() => {
+ return sb1.auth.is_ready()
})
-const get_accounts = query(async () => {
- return await sb1.data.get_accounts()
+const get_accounts = query(() => {
+ return sb1.data.get_accounts()
})
-const get_transactions = query(v.string(), async (accountKey: string) => {
- return await sb1.data.get_transactions(accountKey)
+const get_transactions = query(v.string(), (accountKey: string) => {
+ return sb1.data.get_transactions(accountKey)
})
const clear_auth_session = query(async () => {
await db.delete(syncSession)
})
-const get_auth_info = query(async () => {
- return await sb1.auth.get_auth_info()
+const get_auth_info = query(() => {
+ return sb1.auth.get_auth_info()
})
const refresh_tokem = command(async () => {