// TODO move types import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts" import logger from "@/logger.ts" import dayjs from "dayjs" import { Database } from "better-sqlite3" import { fetchToken, insertTokens, type TokenResponse, } from "@/bank/db/queries.ts" import * as Api from "./sparebank1Api.ts" import { toISODateString } from "@/date.ts" export interface OAuthTokenResponse { access_token: string expires_in: number refresh_token_expires_in: number refresh_token_absolute_expires_in: number token_type: "Bearer" refresh_token: string } export interface Transaction { id: string nonUniqueId: string date: number // Unix time amount: number // Amount in NOK cleanedDescription: string remoteAccountName: string [key: string]: string | number | boolean | unknown } export interface TransactionResponse { transactions: ReadonlyArray } export type Bank = Sparebank1 export interface Sparebank1 { transactionsPastDay: ( accountKeys: ReadonlyArray | string, ) => Promise } export class Sparebank1Impl implements Sparebank1 { private static baseUrl = "https://api.sparebank1.no" private readonly db: Database constructor(db: Database) { this.db = db } private async getAccessToken(): Promise { const accessToken = fetchToken(this.db, "access-token") if (accessToken && this.isValidToken(accessToken)) { return accessToken.token } const response = await this.fetchNewTokens() return response.access_token } private isValidToken(tokenResponse: TokenResponse): boolean { // TODO make sure the same timezone is used. Db uses UTC return dayjs().isBefore(tokenResponse.expires_at) } private async getRefreshToken(): Promise { const tokenResponse = fetchToken(this.db, "refresh-token") logger.debug(`Database returned refresh token: '%o'`, tokenResponse) if (!tokenResponse) { return BANK_INITIAL_REFRESH_TOKEN } else if (this.isValidToken(tokenResponse)) { return tokenResponse.token } // TODO clear database, if refresh token is invalid, will cause Exceptions on each call throw new Error("Refresh token is expired. Create a new one") } async fetchNewTokens(): Promise { const refreshToken = await this.getRefreshToken() logger.debug(`Found refresh token '${refreshToken}'`) const result = await Api.refreshToken(refreshToken) if (result.status === "failure") { throw logger.error({ err: new Error(`Failed to fetch refresh token: '${result.data}'`), }) } const oAuthToken = result.data insertTokens(this.db, oAuthToken) return oAuthToken } async transactionsPastDay( accountKeys: ReadonlyArray | string, ): Promise { const today = dayjs() const lastDay = today.subtract(1, "day") const queries = new URLSearchParams({ // TODO allow multiple accountKeys accountKey: typeof accountKeys === "string" ? accountKeys : accountKeys[0], fromDate: toISODateString(lastDay), toDate: toISODateString(today), }) const accessToken = await this.getAccessToken() logger.debug(`Found access token '${accessToken}'`) const url = `${Sparebank1Impl.baseUrl}/personal/banking/transactions?${queries}` logger.debug(`Sending GET request to '${url}'`) const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.sparebank1.v1+json;charset=utf-8", }, }) logger.debug(`Received response with status '${response.status}'`) if (response.ok) { return response.json() } else { logger.warn(await response.json()) return { transactions: [] } } } }