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 { 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 { 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 { 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 } 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 = { 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] ); }