aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/lib/helpers.ts5
-rw-r--r--app/src/lib/server/actual.ts23
-rw-r--r--app/src/lib/server/db/schema.ts2
-rw-r--r--app/src/lib/server/importer.ts9
-rw-r--r--app/src/lib/server/sb1.ts85
-rw-r--r--app/src/lib/server/session-log.ts14
-rw-r--r--app/src/lib/ui/button.svelte4
-rw-r--r--app/src/routes/+page.server.ts5
-rw-r--r--app/src/routes/+page.svelte88
-rw-r--r--app/src/routes/actual.remote.ts22
-rw-r--r--app/src/routes/sb1-authorize/+server.ts7
-rw-r--r--app/src/routes/sb1.remote.ts5
12 files changed, 181 insertions, 88 deletions
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<SessionLogType>(),
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<any> }
+ if (response.ok) return await response.json() as { accounts: Array<Sb1Account> }
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<string, string> = {
+ 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;
}
}
</style>
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 @@
<script lang="ts">
import Button from "$lib/ui/button.svelte";
- import { clear_auth_session, get_transactions, init_auth_session } from "./sb1.remote";
+ import { clear_auth_session, init_auth_session } from "./sb1.remote";
import type { PageProps } from "./$types";
+ import { Temporal } from "temporal-polyfill";
+ import { instantAsHtmlInputValueString } from "$lib/helpers";
- let navigating = $state(false);
let { data }: PageProps = $props();
+ let navigating = $state(false);
+ let form = $state({
+ budgetId: "",
+ mappings: [] as Array<{ sb1: string; actual: string }>,
+ delta: instantAsHtmlInputValueString(Temporal.Now.instant().subtract("PT24H")),
+ dry: true,
+ });
+ async function run() {}
async function authorize() {
navigating = true;
const url = await init_auth_session();
location.href = url;
- navigating = false;
}
async function logout() {
+ navigating = true;
await clear_auth_session();
+ location.reload();
+ }
+
+ function mappingChanged(sb1: string, actual: string) {
+ let mappings = form.mappings;
+ if (mappings.find((c) => c.sb1 === sb1)) mappings = mappings.filter((c) => c.sb1 !== sb1);
+ mappings.push({ sb1, actual });
+ form.mappings = mappings;
}
+
+ $inspect(form);
</script>
<main>
{#if data.sb1.accounts?.length}
- <ul>
- {#each data.sb1.accounts as account}
- <li>{account.name}</li>
- {#if (await get_transactions(account.key))?.length}
- <ul>
- {#each await get_transactions(account.key) as transaction}
- <li>{JSON.stringify(transaction)}</li>
- {/each}
- </ul>
- {:else}
- <small>Ingen transaksjoner</small>
- {/if}
- {/each}
- </ul>
- <Button onclick={logout}>Logg ut</Button>
+ <form onsubmit={run}>
+ <h3>Importer</h3>
+ <fieldset>
+ <h4>Budsjett</h4>
+ {#each data.actual.budgets as budget}
+ {@const id = `budget-${budget.id}`}
+ <input name="budget" {id} value={budget.id} type="radio" bind:group={form.budgetId} />
+ <label for={id}>{budget.name}({budget.id})</label><br />
+ {/each}
+ <h4>Kontoer</h4>
+ {#each data.sb1.accounts as account}
+ {@const actualId = `mapping-${account.key}-actual`}
+ <div>
+ <code>{account.name}</code>
+ <span>&#8594;</span>
+ <label for={actualId}>Actual</label>
+ <select name={actualId} id={actualId} onchange={(e) => mappingChanged(account.key, e.currentTarget.value)}>
+ <option value="-" selected>-</option>
+ {#each data.actual.accounts as actual}
+ <option value={actual.id}>
+ {actual.name}
+ </option>
+ {/each}
+ </select>
+ </div>
+ {/each}
+ <h4>Ellers</h4>
+ <label for="delta">Importer transaksjoner siden</label>
+ <input type="date" id="delta" bind:value={form.delta} /><br />
+ <input type="checkbox" id="dry" bind:checked={form.dry} /><label for="dry">Tørrkjøring</label><br /><br />
+ <input type="submit" />
+ </fieldset>
+ </form>
+ <h3>Annet</h3>
+ <Button onclick={logout} loading={navigating}>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
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,