From fe0fe074ec8e8959bbdeff0ccc7f68d20b30e963 Mon Sep 17 00:00:00 2001 From: ivar Date: Fri, 26 Dec 2025 13:18:15 +0100 Subject: WIP form --- app/src/lib/helpers.ts | 5 ++ app/src/lib/server/actual.ts | 23 ++++++--- app/src/lib/server/db/schema.ts | 2 + app/src/lib/server/importer.ts | 9 ++++ app/src/lib/server/sb1.ts | 85 +++++++++++++++++++++++++------ app/src/lib/server/session-log.ts | 14 ++++++ app/src/lib/ui/button.svelte | 4 +- app/src/routes/+page.server.ts | 5 +- app/src/routes/+page.svelte | 88 +++++++++++++++++++++------------ app/src/routes/actual.remote.ts | 22 --------- app/src/routes/sb1-authorize/+server.ts | 7 ++- app/src/routes/sb1.remote.ts | 5 -- 12 files changed, 181 insertions(+), 88 deletions(-) create mode 100644 app/src/lib/helpers.ts create mode 100644 app/src/lib/server/importer.ts create mode 100644 app/src/lib/server/session-log.ts delete mode 100644 app/src/routes/actual.remote.ts (limited to 'app/src') diff --git a/app/src/lib/helpers.ts b/app/src/lib/helpers.ts new file mode 100644 index 0000000..35b5f65 --- /dev/null +++ b/app/src/lib/helpers.ts @@ -0,0 +1,5 @@ +import type { Temporal } from "temporal-polyfill"; + +export function instantAsHtmlInputValueString(instant: Temporal.Instant) { + return instant.toString().split("T")[0] +} \ No newline at end of file diff --git a/app/src/lib/server/actual.ts b/app/src/lib/server/actual.ts index 7291aad..0875273 100644 --- a/app/src/lib/server/actual.ts +++ b/app/src/lib/server/actual.ts @@ -1,21 +1,28 @@ -import { ACTUAL_HOST, ACTUAL_PASS } from "$env/static/private"; +import { ACTUAL_BUDGET_ID, 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"; +import path from "node:path" +import process from "node:process"; async function init_actual() { - const dataDir = path.resolve(import.meta.dirname, "actualDataDir"); - - if (!existsSync(dataDir)) mkdirSync(dataDir); - + const dataDir = path.resolve(process.cwd(), "data/actualDataDir") + if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true }); return actual.init({ password: ACTUAL_PASS, serverURL: ACTUAL_HOST, dataDir: dataDir + }).then(async () => { + await actual.downloadBudget(ACTUAL_BUDGET_ID) + await actual.sync() }) } export async function get_budgets() { await init_actual() - return await actual.getBudgets() -} \ No newline at end of file + return actual.getBudgets() +} + +export async function get_accounts() { + await init_actual() + return actual.getAccounts() +} diff --git a/app/src/lib/server/db/schema.ts b/app/src/lib/server/db/schema.ts index bb57703..4d13ed6 100644 --- a/app/src/lib/server/db/schema.ts +++ b/app/src/lib/server/db/schema.ts @@ -1,6 +1,7 @@ import { relations, sql } from 'drizzle-orm'; import { numeric, text, pgTable, uuid, json } from "drizzle-orm/pg-core"; import type { Sb1Tokens } from '../sb1'; +import type { SessionLogType } from '../session-log'; export const syncSession = pgTable("session", { id: uuid('id').primaryKey().default(sql`uuidv7()`), @@ -14,6 +15,7 @@ export const syncLog = pgTable("session_log", { id: uuid('id').primaryKey().default(sql`uuidv7()`), sessionId: text("session_id"), dateTime: text("date_time"), + type: text("type").$type(), msg: text("msg") }) diff --git a/app/src/lib/server/importer.ts b/app/src/lib/server/importer.ts new file mode 100644 index 0000000..91adce0 --- /dev/null +++ b/app/src/lib/server/importer.ts @@ -0,0 +1,9 @@ +import type { Temporal } from "temporal-polyfill"; +import sb1 from "./sb1"; + +async function importSince(account: string, date: Temporal.Instant) { + const accounts = await sb1.data.get_accounts() + for (const account of accounts?.accounts ?? []) { + const transactions = await sb1.data.get_transactions(account.key); + } +} \ No newline at end of file diff --git a/app/src/lib/server/sb1.ts b/app/src/lib/server/sb1.ts index a7cad3e..f6507ef 100644 --- a/app/src/lib/server/sb1.ts +++ b/app/src/lib/server/sb1.ts @@ -4,6 +4,7 @@ import { Temporal } from "temporal-polyfill"; import { randomUUID } from "node:crypto"; import { db } from "./db"; import { syncSession } from "./db/schema"; +import { add_session_log } from "./session-log"; export type Sb1Tokens = { access_token: string @@ -14,12 +15,12 @@ export type Sb1Tokens = { refresh_token: string } -export type Transaction = { +export type Sb1Transaction = { id: string nonUniqueId: string description: string cleanedDescription: string - accountNumber: AccountNumber + accountNumber: Sb1AccountNumber amount: number date: number interestDate: number @@ -34,16 +35,44 @@ export type Transaction = { accountKey: string accountCurrency: string isFromCurrencyAccount: boolean - classificationInput: ClassificationInput + classificationInput: Sb1ClassificationInput } -export type AccountNumber = { +export type Sb1Account = { + key: string; + accountNumber: string; + iban: string; + name: string; + description: string; + balance: number; + availableBalance: number; + currencyCode: string; + owner: Sb1AccountOwner; + productType: string; + type: string; + productId: string; + descriptionCode: string; + accountProperties: { [key: string]: boolean }; +} + +export type Sb1AccountOwner = { + name: string; + firstName: string; + lastName: string; + type: string; + age: number; + customerKey: string; + ssnKey: string; +} + + +export type Sb1AccountNumber = { value: string formatted: string unformatted: string } -export type ClassificationInput = { +export type Sb1ClassificationInput = { id: string amount: number type: string @@ -139,12 +168,13 @@ const auth = { const epoch = Temporal.Now.instant().epochMilliseconds if (res.ok) { await db.update(syncSession).set({ tokens, accessTokenCreated: epoch.toString(), refreshTokenCreated: epoch.toString() }).where(eq(syncSession.id, id)) + await add_session_log(id, "REFRESH_SB1_TOKEN", "Done") return tokens } else { console.error("Failed to refresh tokens", tokens) + await add_session_log(id, "REFRESH_SB1_TOKEN", "Failed: " + JSON.stringify(res)) return null } - } } @@ -160,23 +190,50 @@ const data = { Authorization: `Bearer ${token}` } }) - if (response.ok) return await response.json() as { accounts: Array } + if (response.ok) return await response.json() as { accounts: Array } else console.error(await response.text()) }, - async get_transactions(accountKey: string) { + async get_transactions(accountKey: string, delta?: Temporal.Instant) { const token = await auth.get_access_token() + if (!token) return undefined - const response = await fetch("https://api.sparebank1.no/personal/banking/transactions?" + new URLSearchParams({ - "accountKey": accountKey - }), { + const params = new URLSearchParams({ + "accountKey": accountKey, + }); + if (delta) params.append("fromDate", formatInstant(delta, "yyyy-MM-dd")) + const response = await fetch("https://api.sparebank1.no/personal/banking/transactions?" + params, { headers: { Authorization: `Bearer ${token}`, }, }); const json = await response.json() - console.log(accountKey + ":" + json["transactions"]?.length) - return json["transactions"] as Transaction[]; + return json["transactions"] as Sb1Transaction[]; } } -export default { auth, data } \ No newline at end of file +export default { auth, data } + +export function formatInstant( + instant: Temporal.Instant, + format: string, + timeZone: string = "UTC" +): string { + const zdt = instant.toZonedDateTimeISO(timeZone); + + const pad = (value: number, length = 2) => + value.toString().padStart(length, "0"); + + const replacements: Record = { + yyyy: pad(zdt.year, 4), + MM: pad(zdt.month), + dd: pad(zdt.day), + HH: pad(zdt.hour), + mm: pad(zdt.minute), + ss: pad(zdt.second), + }; + + return format.replace( + /yyyy|MM|dd|HH|mm|ss/g, + (token) => replacements[token] + ); +} diff --git a/app/src/lib/server/session-log.ts b/app/src/lib/server/session-log.ts new file mode 100644 index 0000000..1195621 --- /dev/null +++ b/app/src/lib/server/session-log.ts @@ -0,0 +1,14 @@ +import { Temporal } from "temporal-polyfill" +import { db } from "./db" +import { syncLog } from "./db/schema" + +export type SessionLogType = "CREATED" | "SYNC_START" | "REFRESH_SB1_TOKEN" + +export async function add_session_log(id: string, type: SessionLogType, msg: string) { + db.insert(syncLog).values({ + dateTime: String(Temporal.Now.instant().epochMilliseconds), + sessionId: id, + type: type, + msg: msg + }) +} \ No newline at end of file diff --git a/app/src/lib/ui/button.svelte b/app/src/lib/ui/button.svelte index 37b1c97..ad82f57 100644 --- a/app/src/lib/ui/button.svelte +++ b/app/src/lib/ui/button.svelte @@ -24,7 +24,7 @@ cursor: pointer; display: flex; gap: 3px; - transition: 0.1s all ease; + transition: 0.075s all ease; height: fit-content; &:hover, @@ -35,7 +35,7 @@ &:active { background-color: rgba(0, 0, 0, 0.2); transform: scale(0.96); - transition: 0.2s all ease; + transition: 0.15s all ease; } } diff --git a/app/src/routes/+page.server.ts b/app/src/routes/+page.server.ts index 0d6016b..5d3e857 100644 --- a/app/src/routes/+page.server.ts +++ b/app/src/routes/+page.server.ts @@ -1,11 +1,12 @@ import type { PageServerLoad } from './$types'; -import { get_budgets } from '$lib/server/actual'; +import { get_accounts, get_budgets } from '$lib/server/actual'; import sb1 from "$lib/server/sb1" export const load = (async () => { return { actual: { - meta: await get_budgets() + budgets: await get_budgets(), + accounts: await get_accounts(), }, sb1: { accounts: (await sb1.data.get_accounts())?.accounts diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index 26db3e4..7b0a495 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -1,54 +1,80 @@
{#if data.sb1.accounts?.length} -
    - {#each data.sb1.accounts as account} -
  • {account.name}
  • - {#if (await get_transactions(account.key))?.length} -
      - {#each await get_transactions(account.key) as transaction} -
    • {JSON.stringify(transaction)}
    • - {/each} -
    - {:else} - Ingen transaksjoner - {/if} - {/each} -
- +
+

Importer

+
+

Budsjett

+ {#each data.actual.budgets as budget} + {@const id = `budget-${budget.id}`} + +
+ {/each} +

Kontoer

+ {#each data.sb1.accounts as account} + {@const actualId = `mapping-${account.key}-actual`} +
+ {account.name} + + + +
+ {/each} +

Ellers

+ +
+

+ +
+
+

Annet

+ {:else} {/if} - - {#if data.actual.meta} -
{JSON.stringify(data.actual.meta, null, 2)}
- {/if}
- - diff --git a/app/src/routes/actual.remote.ts b/app/src/routes/actual.remote.ts deleted file mode 100644 index 9560d4d..0000000 --- a/app/src/routes/actual.remote.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { command, query } from "$app/server"; -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 const get_actual_meta = query(async () => { - await init_actual() - return await actual.getBudgets() -}) diff --git a/app/src/routes/sb1-authorize/+server.ts b/app/src/routes/sb1-authorize/+server.ts index e08db3e..b3a0cf7 100644 --- a/app/src/routes/sb1-authorize/+server.ts +++ b/app/src/routes/sb1-authorize/+server.ts @@ -31,15 +31,14 @@ export const GET: RequestHandler = async ({ url }) => { body: fd }) - const text = await response.text() + const json = await response.json() if (response.ok) { const epoch = Temporal.Now.instant().epochMilliseconds - await db.update(syncSession).set({ tokens: text, accessTokenCreated: epoch, refreshTokenCreated: epoch }).where(eq(syncSession.id, id)) + await db.update(syncSession).set({ tokens: json, accessTokenCreated: epoch.toString(), refreshTokenCreated: epoch.toString() }).where(eq(syncSession.id, id)) redirect(302, "/") } else { - console.error(text) - return new Response(text) + return new Response(json) } return new Response() diff --git a/app/src/routes/sb1.remote.ts b/app/src/routes/sb1.remote.ts index 9a2167e..1ee0155 100644 --- a/app/src/routes/sb1.remote.ts +++ b/app/src/routes/sb1.remote.ts @@ -8,10 +8,6 @@ const init_auth_session = command(async () => { return await sb1.auth.init_auth_session() }) -const is_ready = query(() => { - return sb1.auth.is_ready() -}) - const get_accounts = query(() => { return sb1.data.get_accounts() }) @@ -35,7 +31,6 @@ const refresh_tokem = command(async () => { export { refresh_tokem, init_auth_session, - is_ready, get_accounts, get_transactions, clear_auth_session, -- cgit v1.3