diff options
Diffstat (limited to 'app/src/lib')
| -rw-r--r-- | app/src/lib/helpers.ts | 5 | ||||
| -rw-r--r-- | app/src/lib/server/actual.ts | 72 | ||||
| -rw-r--r-- | app/src/lib/server/db/index.ts | 6 | ||||
| -rw-r--r-- | app/src/lib/server/db/schema.ts | 36 | ||||
| -rw-r--r-- | app/src/lib/server/sb1.ts | 207 | ||||
| -rw-r--r-- | app/src/lib/server/session-log.ts | 14 | ||||
| -rw-r--r-- | app/src/lib/shared.ts | 154 | ||||
| -rw-r--r-- | app/src/lib/ui/button.svelte | 41 |
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> |
