aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/lib')
-rw-r--r--app/src/lib/helpers.ts5
-rw-r--r--app/src/lib/server/actual.ts72
-rw-r--r--app/src/lib/server/db/index.ts6
-rw-r--r--app/src/lib/server/db/schema.ts36
-rw-r--r--app/src/lib/server/sb1.ts207
-rw-r--r--app/src/lib/server/session-log.ts14
-rw-r--r--app/src/lib/shared.ts154
-rw-r--r--app/src/lib/ui/button.svelte41
8 files changed, 0 insertions, 535 deletions
diff --git a/app/src/lib/helpers.ts b/app/src/lib/helpers.ts
deleted file mode 100644
index 35b5f65..0000000
--- a/app/src/lib/helpers.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-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
deleted file mode 100644
index ca4d9c4..0000000
--- a/app/src/lib/server/actual.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { ACTUAL_FILE_ID, ACTUAL_HOST, ACTUAL_PASS } from "$env/static/private";
-import * as actualApi from "@actual-app/api"
-import { existsSync, mkdirSync } from "node:fs";
-import path from "node:path"
-import process from "node:process";
-import type { ImportTransactionEntity } from "@actual-app/api/@types/loot-core/src/types/models/import-transaction";
-import { Temporal } from "temporal-polyfill";
-import type { Sb1Transaction } from "$lib/shared";
-
-let inited = false
-
-async function init() {
- if (inited) return
- const dataDir = path.resolve(process.cwd(), "data/actualDataDir")
- if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
- await actualApi.init({
- password: ACTUAL_PASS,
- serverURL: ACTUAL_HOST,
- dataDir
- })
- await actualApi.downloadBudget(ACTUAL_FILE_ID)
- await actualApi.sync()
- inited = true
-}
-
-const budget = {
- async get_budgets() {
- await init()
- return actualApi.getBudgets()
- },
-
- async get_accounts() {
- await init()
- return actualApi.getAccounts()
- },
-
- async import_transactions(account: string, transactions: Sb1Transaction[], dryRun: boolean) {
- await init()
-
- function parsedDate(date: number) {
- return Temporal.Instant.fromEpochMilliseconds(date)
- .toString({ timeZone: "Europe/Oslo" })
- .split("T")[0]
- }
-
- function notes(transaction: Sb1Transaction) {
- const { description, cleanedDescription } = transaction
- if (description?.toLowerCase().trim() === cleanedDescription?.toLowerCase().trim()) return undefined
- return description
- }
-
- function amount(amount: number) {
- const res = Math.round(amount * 10000)
- console.log(`${amount}->${res}`)
- return res
- }
-
- const mapped: ImportTransactionEntity[] = transactions
- .filter(c => c.bookingStatus === "BOOKED")
- .map(c => ({
- account,
- date: parsedDate(c.date),
- amount: amount(c.amount),
- notes: notes(c),
- payee_name: c.cleanedDescription
- }))
-
- return actualApi.importTransactions(account, mapped, { dryRun })
- }
-}
-
-export default { init, budget }
diff --git a/app/src/lib/server/db/index.ts b/app/src/lib/server/db/index.ts
deleted file mode 100644
index e477388..0000000
--- a/app/src/lib/server/db/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { drizzle } from 'drizzle-orm/node-postgres';
-import { env } from '$env/dynamic/private';
-
-if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
-
-export const db = drizzle(env.DATABASE_URL); \ No newline at end of file
diff --git a/app/src/lib/server/db/schema.ts b/app/src/lib/server/db/schema.ts
deleted file mode 100644
index dbff3a2..0000000
--- a/app/src/lib/server/db/schema.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { relations, sql } from 'drizzle-orm';
-import { numeric, text, pgTable, uuid, jsonb } from "drizzle-orm/pg-core";
-import type { SessionLogType } from '../session-log';
-import type { Sb1Tokens, Sb1Transaction, Sb1TransactionDetails } from '$lib/shared';
-
-export const SyncSessionTable = pgTable("session", {
- id: uuid('id').primaryKey().default(sql`uuidv7()`),
- authzState: text("authzState"),
- accessTokenCreated: numeric("accessTokenCreated"),
- refreshTokenCreated: numeric("refreshTokenCreated"),
- tokens: jsonb("tokens").$type<Sb1Tokens>()
-})
-
-export const SyncLogTable = 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")
-})
-
-export const TransactionsTable = pgTable("transactions", {
- transaction: jsonb("transaction").$type<Sb1Transaction>(),
- details: jsonb("details").$type<Sb1TransactionDetails>()
-})
-
-export const SyncLogRelation = relations(SyncLogTable, ({ one }) => ({
- author: one(SyncSessionTable, {
- fields: [SyncLogTable.sessionId],
- references: [SyncSessionTable.id],
- })
-}))
-
-export const SyncSessionLogRelation = relations(SyncSessionTable, ({ many }) => ({
- logs: many(SyncLogTable)
-}))
diff --git a/app/src/lib/server/sb1.ts b/app/src/lib/server/sb1.ts
deleted file mode 100644
index 0a51649..0000000
--- a/app/src/lib/server/sb1.ts
+++ /dev/null
@@ -1,207 +0,0 @@
-import { SB1_FIN_INST, SB1_ID, SB1_REDIRECT_URI, SB1_SECRET } from "$env/static/private";
-import { eq, sql } from "drizzle-orm";
-import { Temporal } from "temporal-polyfill";
-import { randomUUID } from "node:crypto";
-import { db } from "./db";
-import { SyncSessionTable, TransactionsTable } from "./db/schema";
-import { add_session_log } from "./session-log";
-import type { Sb1Account, Sb1Tokens, Sb1Transaction } from "$lib/shared";
-
-const auth = {
- async is_ready(): Promise<boolean> {
- const token = await this.get_access_token()
- return token !== ""
- },
- async get_auth_info() {
- const entity = await db.select({
- refreshTokenCreated: SyncSessionTable.refreshTokenCreated,
- accessTokenCreated: SyncSessionTable.accessTokenCreated,
- tokens: SyncSessionTable.tokens
- }).from(SyncSessionTable)
- if (!entity[0]) return undefined
- const { tokens, accessTokenCreated, refreshTokenCreated } = entity[0]
- if (!tokens) return undefined
- const refreshTokenExpires = Temporal.Instant.fromEpochMilliseconds(Number(refreshTokenCreated)).add({ seconds: tokens.refresh_token_expires_in })
- const accessTokenExpires = Temporal.Instant.fromEpochMilliseconds(Number(accessTokenCreated)).add({ seconds: tokens.expires_in })
- return {
- refreshTokenExpires,
- accessTokenExpires
- }
- },
- async init_auth_session(): Promise<string> {
- const state = randomUUID()
-
- await db.insert(SyncSessionTable).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 result = await db.select({
- tokens: SyncSessionTable.tokens,
- refreshTokenCreated: SyncSessionTable.refreshTokenCreated,
- accessTokenCreated: SyncSessionTable.accessTokenCreated
- }).from(SyncSessionTable)
-
- if (!result[0]) return undefined
-
- const { tokens, accessTokenCreated, refreshTokenCreated } = result[0]
-
- if (!tokens) return undefined
-
- const nowInstant = Temporal.Now.instant()
-
- const accessTokenExpiredInstant = Temporal.Instant.fromEpochMilliseconds(Number(accessTokenCreated)).add({ seconds: tokens.expires_in })
- if (Temporal.Instant.compare(nowInstant, accessTokenExpiredInstant) >= 0) {
- const refreshedTokens = await this.refresh_token()
- if (refreshedTokens) return refreshedTokens.access_token
- }
-
- const refreshTokenExpiredInstant = Temporal.Instant.fromEpochMilliseconds(Number(refreshTokenCreated)).add({ seconds: tokens.refresh_token_expires_in })
- if (Temporal.Instant.compare(nowInstant, refreshTokenExpiredInstant) >= 0) {
- return undefined
- }
-
- return tokens?.access_token as string
- },
- async refresh_token(): Promise<Sb1Tokens | null> {
- console.log("Refreshing tokens")
- const entity = await db.select({
- tokens: SyncSessionTable.tokens,
- id: SyncSessionTable.id
- }).from(SyncSessionTable)
-
- const { tokens: currentTokens, id } = entity[0]
-
- if (!currentTokens) return null
- if (!currentTokens.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", currentTokens.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 tokens = await res.json() as Sb1Tokens
- const epoch = Temporal.Now.instant().epochMilliseconds
- if (res.ok) {
- await db.update(SyncSessionTable).set({ tokens, accessTokenCreated: epoch.toString(), refreshTokenCreated: epoch.toString() }).where(eq(SyncSessionTable.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
- }
- }
-}
-
-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<Sb1Account> }
- else console.error(await response.text())
- },
- async get_transactions(accountKey: string, delta?: Temporal.Instant) {
- const token = await auth.get_access_token()
- if (!token) return undefined
-
- const params = new URLSearchParams({
- "accountKey": accountKey,
- });
-
- if (delta) {
- params.append("fromDate", formatInstant(delta, "yyyy-MM-dd"))
- params.append("Transaction source", "ALL")
- }
-
- const response = await fetch("https://api.sparebank1.no/personal/banking/transactions?" + params, {
- headers: {
- Authorization: `Bearer ${token}`,
- Accept: "application/vnd.sparebank1.v1+json;charset=utf-8"
- },
- });
-
- const json = await response.json()
- return json["transactions"] as Sb1Transaction[]
- }
-}
-
-export default { auth, data, init }
-
-let importInterval: NodeJS.Timeout
-let inited = false
-
-async function init() {
- if (inited) return
- if (importInterval) clearInterval(importInterval)
- await importTransactions()
- importInterval = setInterval(async () => importTransactions, 60 * 60 * 1000)
- inited = true
-}
-
-async function importTransactions() {
- console.log("Creating sb1 transactions indb")
- const accounts = await data.get_accounts()
- for (const account of accounts?.accounts ?? []) {
- const transactions = await data.get_transactions(account.key)
- for (const transaction of transactions ?? []) {
- // if (await transactionExists(transaction.id)) continue
- await db.insert(TransactionsTable).values({ transaction })
- }
- }
-}
-
-async function transactionExists(transactionId: string) {
- const query = sql`select data ->>'id' as id from ${TransactionsTable} where id=${transactionId}`;
- return (await db.execute(query)).rowCount ?? 0 > 0
-}
-
-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]
- );
-} \ No newline at end of file
diff --git a/app/src/lib/server/session-log.ts b/app/src/lib/server/session-log.ts
deleted file mode 100644
index 54498f3..0000000
--- a/app/src/lib/server/session-log.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Temporal } from "temporal-polyfill"
-import { db } from "./db"
-import { SyncLogTable } 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(SyncLogTable).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/shared.ts b/app/src/lib/shared.ts
deleted file mode 100644
index cc4472f..0000000
--- a/app/src/lib/shared.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import * as v from 'valibot'
-
-export type ImportForm = v.InferOutput<typeof ImportForm>
-export const ImportForm = v.object({
- budgetId: v.string(),
- mappings: v.array(
- v.object({
- sb1Id: v.string(),
- actualId: v.string()
- })
- ),
- dryRun: v.boolean()
-})
-export type Sb1TransactionDetails = {
- id: string;
- date: Date;
- type: string;
- amount: number;
- typeCode: string;
- typeText: string;
- valueDate: Date;
- accountKey: string;
- bookedDate: Date;
- accountName: string;
- description: string;
- eInvoiceUrl: string;
- nonUniqueId: string;
- postingDate: Date;
- currencyCode: string;
- exchangeRate: number;
- kidOrMessage: string;
- accountNumber: number;
- currencyAmount: number;
- paymentDetails: Sb1PaymentDetails;
- accountCurrency: string;
- archiveReference: string;
- paymentReference: string;
- remoteAccountName: string;
- cleanedDescription: string;
- numericalReference: string;
- classificationInput: Sb1ClassificationInput;
- originalDescription: string;
- remoteAccountNumber: string;
-}
-
-export type Sb1PaymentDetails = {
- amount: number;
- message: string;
- paymentCid: string;
- payeeAddress: Sb1PayeeAddress;
- payeeBankName: string;
- payeeBicSwift: string;
- amountCurrency: string;
- serviceCharges: Sb1ServiceCharge[];
- payeeBankAddress: Sb1PayeeAddress;
- paymentReference: string;
- payeeEmailAddress: string;
- internationalDetails: Sb1InternationalDetails;
-}
-
-export type Sb1InternationalDetails = {
- agreedRate: string;
- agreedWith: string;
- authorityReportCode: string;
- authorityReportText: string;
-}
-
-export type Sb1PayeeAddress = {
- city: string;
- line1: string;
- line2: string;
- line3: string;
- zipCode: string;
- countryCode: string;
-}
-
-export type Sb1ServiceCharge = {
- paidBy: string;
- chargedAmount: number;
- chargedAmountCurrency: string;
-}
-
-export 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
-}
-
-export type Sb1Transaction = {
- id: string
- nonUniqueId: string
- description: string
- cleanedDescription: string
- accountNumber: Sb1AccountNumber
- amount: number
- date: number
- interestDate: number
- typeCode: string
- typeText: string
- currencyCode: string
- canShowDetails: boolean
- source: string
- isConfidential: boolean
- bookingStatus: string
- accountName: string
- accountKey: string
- accountCurrency: string
- isFromCurrencyAccount: boolean
- classificationInput: Sb1ClassificationInput
-}
-
-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 Sb1ClassificationInput = {
- id: string
- amount: number
- type: string
- text: string
- date: string
-}
diff --git a/app/src/lib/ui/button.svelte b/app/src/lib/ui/button.svelte
deleted file mode 100644
index ad82f57..0000000
--- a/app/src/lib/ui/button.svelte
+++ /dev/null
@@ -1,41 +0,0 @@
-<script lang="ts">
- import type { HTMLButtonAttributes } from "svelte/elements";
- let { children, loading, type = "button", ...restProps }: Props = $props();
-
- type Props = {
- loading?: boolean;
- } & HTMLButtonAttributes;
-</script>
-
-<button {...restProps} {type}>
- {@render children?.()}
- {#if loading}
- ...
- {/if}
-</button>
-
-<style>
- button {
- border: 1px solid rgba(0, 0, 0, 0.3);
- background-color: rgba(0, 0, 0, 0.1);
- align-items: center;
- border-radius: 3px;
- padding: 2px 4px;
- cursor: pointer;
- display: flex;
- gap: 3px;
- transition: 0.075s all ease;
- height: fit-content;
-
- &:hover,
- &:focus {
- background-color: rgba(0, 0, 0, 0.15);
- }
-
- &:active {
- background-color: rgba(0, 0, 0, 0.2);
- transform: scale(0.96);
- transition: 0.15s all ease;
- }
- }
-</style>