diff options
| author | ivar <i@oiee.no> | 2025-12-11 00:44:59 +0100 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2025-12-11 00:44:59 +0100 |
| commit | 008862f8a2431c8f755a38a0ef242b8faf125057 (patch) | |
| tree | 64087987855e95940110b3c65fc877921574dc8f | |
| parent | 3dfc7b11ca5b243c63c90bb3c2fafeb8e14dc7f0 (diff) | |
| download | sparebank1-actualbudget-008862f8a2431c8f755a38a0ef242b8faf125057.tar.xz sparebank1-actualbudget-008862f8a2431c8f755a38a0ef242b8faf125057.zip | |
WIP! Restructure
| -rw-r--r-- | app/src/lib/server/sb1.ts | 144 | ||||
| -rw-r--r-- | app/src/routes/+page.svelte | 69 | ||||
| -rw-r--r-- | app/src/routes/actual.remote.ts | 9 | ||||
| -rw-r--r-- | app/src/routes/sb1.remote.ts | 192 |
4 files changed, 201 insertions, 213 deletions
diff --git a/app/src/lib/server/sb1.ts b/app/src/lib/server/sb1.ts new file mode 100644 index 0000000..c060af5 --- /dev/null +++ b/app/src/lib/server/sb1.ts @@ -0,0 +1,144 @@ +import { SB1_FIN_INST, SB1_ID, SB1_REDIRECT_URI, SB1_SECRET } from "$env/static/private"; +import { eq } from "drizzle-orm"; +import { Temporal } from "temporal-polyfill"; +import { randomUUID } from "node:crypto"; +import { db } from "./db"; +import { syncSession } from "./db/schema"; + +type Sb1Tokens = { + access_token: string + expires_in: number + refresh_token_expires_in: number + refresh_token_absolute_expires_in: number + token_type: string + refresh_token: string +} + +type Transaction = { + description: string + amount: number + date: string + mcc: string +} + +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 + }, + async get_auth_info() { + const entity = await db.select({ + refreshTokenCreated: syncSession.refreshTokenCreated, + accessTokenCreated: syncSession.accessTokenCreated, + tokens: syncSession.tokens + }).from(syncSession) + if (!entity[0]) return undefined + const { tokens, accessTokenCreated, refreshTokenCreated } = entity[0] + 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 }) + return { + refreshTokenExpires, + accessTokenExpires + } + }, + async init_auth_session(): Promise<string> { + const state = randomUUID() + + await db.insert(syncSession).values({ + authzState: state + }) + + 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 + return parsed.access_token as string + }, + async refresh_tokem() { + const entity = await db.select({ + tokens: syncSession.tokens, + id: syncSession.id + }).from(syncSession) + + const { tokens, id } = entity[0] + + if (!tokens) return null + + const parsed = JSON.parse(tokens) as Sb1Tokens + + if (!parsed.refresh_token) throw new Error("No refresh token"); + + const params = new URLSearchParams(); + + params.set("client_id", SB1_ID); + params.set("client_secret", SB1_SECRET); + params.set("refresh_token", parsed.refresh_token); + params.set("grant_type", "refresh_token"); + + const res = await fetch("https://api.sparebank1.no/oauth/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + body: params, + }); + + const text = await res.text(); + const epoch = Temporal.Now.instant().epochMilliseconds + await db.update(syncSession).set({ tokens: text, accessTokenCreated: epoch, refreshTokenCreated: epoch }).where(eq(syncSession.id, id)) + } +} + +const data = { + async get_accounts() { + const token = await auth.get_access_token() + if (!token) return undefined + const url = new URL( + "https://api.sparebank1.no/personal/banking/accounts", + ); + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}` + } + }) + if (response.ok) return await response.json() as { accounts: Array<any> } + else console.error(await response.text()) + }, + 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", + ); + url.searchParams.set("accountKey", accountKey); + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return (await response.json())["transactions"] as Transaction[]; + + } +} + +export default { auth, data }
\ No newline at end of file diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index 005eb34..e89df9b 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -1,68 +1,51 @@ <script lang="ts"> import Button from "$lib/ui/button.svelte"; - import { - clearTokens, - createSb1SyncSessionAndReturnLoginUrl, - getAccounts, - getTokenExpires, - getTransactions, - refreshSB1Token, - } from "./sb1.remote"; + 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"; let navigating = $state(false); async function authorize() { navigating = true; - const url = await createSb1SyncSessionAndReturnLoginUrl(); + const url = await init_auth_session(); location.href = url; navigating = false; } - async function clearAuth() { - await clearTokens(); - getTokenExpires().refresh(); + async function logout() { + await clear_auth_session(); } - async function refreshAuth() { - await refreshSB1Token(); - getTokenExpires().refresh(); - } - - async function initActual() { - await initActual() - } + onMount(async () => { + await get_actual_meta(); + }); </script> <main> - {#if await getTokenExpires()} - {@const tokens = await getTokenExpires()} - {@const accounts = await getAccounts()} - {#if tokens} - <pre>accessToken: {tokens.accessToken.created.add({ seconds: tokens.accessToken.expires }).toLocaleString()} -refreshToken: {tokens.refreshToken.created.add({ seconds: tokens.refreshToken.expires }).toLocaleString()}</pre> - <ul> - {#each accounts?.accounts as account} - {@const transactions = await getTransactions(account.key)} - <li>{account.name}</li> + {#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)} + <li>{account.name}</li> + {#if transactions?.length} <ul> - {#each transactions?.transactions as transaction} + {#each transactions as transaction} <li>{JSON.stringify(transaction)}</li> {/each} </ul> - {/each} - </ul> - <Button onclick={clearAuth}>Slett autorisasjon</Button> - <Button onclick={refreshAuth}>Oppdater autorisasjon</Button> - <Button></Button> + {:else} + <small>Ingen transaksjoner</small> + {/if} + {/each} {/if} + {#if actual_meta} + <pre>{JSON.stringify(actual_meta, null, 2)}</pre> + {/if} + <Button onclick={logout}>Logg ut</Button> {:else} <Button onclick={authorize} loading={navigating}>Autentisér hos Sparebanken 1</Button> {/if} </main> - -<style> - pre { - max-width: 50vw; - overflow: auto; - } -</style> diff --git a/app/src/routes/actual.remote.ts b/app/src/routes/actual.remote.ts index 535e387..4bd70b4 100644 --- a/app/src/routes/actual.remote.ts +++ b/app/src/routes/actual.remote.ts @@ -4,7 +4,7 @@ import * as actual from "@actual-app/api" import { existsSync, mkdirSync } from "node:fs"; import path from "node:path"; -async function initActual() { +async function init_actual() { const dataDir = path.resolve(__dirname, "actualDataDir"); if (!existsSync(dataDir)) mkdirSync(dataDir); @@ -16,8 +16,7 @@ async function initActual() { }) } -export const getActualMeta = query(async () => { - await initActual() - const accounts = await actual.getAccounts() - return +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 17e1ead..d2eb0cc 100644 --- a/app/src/routes/sb1.remote.ts +++ b/app/src/routes/sb1.remote.ts @@ -1,181 +1,43 @@ -import { SB1_FIN_INST, SB1_ID, SB1_REDIRECT_URI, SB1_SECRET } from "$env/static/private"; -import { randomUUID } from "node:crypto"; -import { db } from "../lib/server/db"; -import { syncSession } from "../lib/server/db/schema"; +import { db } from "$lib/server/db"; +import { syncSession } from "$lib/server/db/schema"; import * as v from "valibot" import { command, query } from "$app/server"; -import { eq } from "drizzle-orm"; -import { Temporal } from "temporal-polyfill"; +import sb1 from "$lib/server/sb1"; -export const createSb1SyncSessionAndReturnLoginUrl = command(async () => { - return createSb1Auth() +const init_auth_session = command(async () => { + return await sb1.auth.init_auth_session() }) -async function createSb1Auth() { - const state = randomUUID() - - await db.insert(syncSession).values({ - authzState: state - }) - - 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() -} - -export const getAccounts = query(async () => { - const token = await getSb1AccessToken() - if (!token) return undefined - const url = new URL( - "https://api.sparebank1.no/personal/banking/accounts", - ); - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}` - }, - }); - if (response.ok) { - return await response.json() as { accounts: Array<any> } - } - else console.error(await response.text()) +const is_ready = query(async () => { + return await sb1.auth.is_ready() }) -export const getTransactions = query(v.string(), async (accountKey: string) => { - const token = await getSb1AccessToken() - if (token) return undefined - const url = new URL( - "https://api.sparebank1.no/personal/banking/transactions", - ); - url.searchParams.set("accountKey", accountKey); - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - return (await response.json()) as TransactionsResponse; +const get_accounts = query(async () => { + return await sb1.data.get_accounts() }) -async function getSb1AccessToken() { - const entity = await db.select({ - tokens: syncSession.tokens - }).from(syncSession) - const { tokens } = entity[0] - if (!tokens) return null - const parsed = JSON.parse(tokens) - return parsed.access_token as string -} +const get_transactions = query(v.string(), async (accountKey: string) => { + return await sb1.data.get_transactions(accountKey) +}) -export const clearTokens = query(async () => { +const clear_auth_session = query(async () => { await db.delete(syncSession) }) -export const getTokenExpires = query(async () => { - const entity = await db.select({ - refreshTokenCreated: syncSession.refreshTokenCreated, - accessTokenCreated: syncSession.accessTokenCreated, - tokens: syncSession.tokens - }).from(syncSession) - if (!entity[0]) return undefined - const { tokens, accessTokenCreated, refreshTokenCreated } = entity[0] - const tokensParsed = JSON.parse(tokens ?? "") - return { - accessToken: { - expires: tokensParsed?.expires_in ?? 0, - created: Temporal.Instant.fromEpochMilliseconds(Number(accessTokenCreated)) - }, - refreshToken: { - expires: tokensParsed?.refresh_token_expires_in ?? 0, - created: Temporal.Instant.fromEpochMilliseconds(Number(refreshTokenCreated)) - }, - } +const get_auth_info = query(async () => { + return await sb1.auth.get_auth_info() }) -type Sb1Tokens = { - access_token: string, - expires_in: number, - refresh_token_expires_in: number, - refresh_token_absolute_expires_in: number, - token_type: string, - refresh_token: string -} - -type TokenAction = "Empty" - -const auth = { - async ready(): Promise<boolean> { - const token = await this.tokenOrAction() - const ping = await fetch("https://developer.sparebank1.no/helloworld/ping", { - headers: { - "Authorization": "Bearer " + token - } - }) - return ping.ok - }, - async tokenOrAction(): Promise<TokenAction | string> { - const entity = await db.select({ - refreshTokenCreated: syncSession.refreshTokenCreated, - accessTokenCreated: syncSession.accessTokenCreated, - tokens: syncSession.tokens - }).from(syncSession) - const { tokens, accessTokenCreated, refreshTokenCreated } = entity[0] - if (!tokens) return "Empty" - const json = JSON.parse(tokens) as Sb1Tokens - if (!Object.hasOwn(json, "access_token")) return TokenAction.Empty - return json.access_token - }, - getAccessToken() { }, - async getRefreshToken() { - const entity = await db.select({ - tokens: syncSession.tokens, - id: syncSession.id - }).from(syncSession) - - const { tokens, id } = entity[0] - - if (!tokens) return null - - const parsed = JSON.parse(tokens) - - if (!parsed.refresh_token) throw new Error("No refresh token"); - - const fd = new URLSearchParams(); - - fd.set("client_id", SB1_ID); - fd.set("client_secret", SB1_SECRET); - fd.set("refresh_token", parsed.refresh_token); - fd.set("grant_type", "refresh_token"); - - const res = await fetch("https://api.sparebank1.no/oauth/token", { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - method: "post", - body: fd, - }); - - const text = await res.text(); - const epoch = Temporal.Now.instant().epochMilliseconds - await db.update(syncSession).set({ tokens: text, accessTokenCreated: epoch, refreshTokenCreated: epoch }).where(eq(syncSession.id, id)) - } -} - -export const refreshSB1Token = command(async () => { - auth.getRefreshToken() -}); - -export type Transaction = { - description: string; - amount: number; - date: string; - mcc: string; -} +const refresh_tokem = command(async () => { + await sb1.auth.refresh_tokem() +}) -export type TransactionsResponse = { - transactions: Array<Transaction>; -} +export { + refresh_tokem, + init_auth_session, + is_ready, + get_accounts, + get_transactions, + clear_auth_session, + get_auth_info, +}
\ No newline at end of file |
