diff --git a/.env.example b/.env.example index 5be48ba..ed1c2c2 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,15 @@ +# Actual Budget ACTUAL_BUDGET_ID=your-budget-id ACTUAL_SYNC_ID=your-sync-id -ACTUAL_SERVER_URL=your-server-url +ACTUAL_SERVER_URL=http://your-server-url:5006 ACTUAL_PASSWORD=your-password ACTUAL_ACCOUNT_IDS=your-account-id1,your-account-id2 # Bank +BANK_INITIAL_REFRESH_TOKEN=initial-valid-refresh-token BANK_OAUTH_CLIENT_ID=your-client-id BANK_OAUTH_CLIENT_SECRET=your-client-secret BANK_OAUTH_STATE=your-state -BANK_OAUTH_REDIRECT_URI=your-redirect-uri +BANK_OAUTH_REDIRECT_URI=http://your-redirect-uri.com BANK_ACCOUNT_IDS=your-account-id1,your-account-id2 # Configuration -# trace | error | warn | info | debug | trace -LOG_LEVEL=info +LOG_LEVEL=info# trace | error | warn | info | debug | trace diff --git a/config.ts b/config.ts index a704e30..7720f08 100644 --- a/config.ts +++ b/config.ts @@ -3,17 +3,17 @@ import dotenv from "dotenv" dotenv.config() -export const ACTUAL_BUDGET_ID = getOrThrow("ACTUAL_BUDGET_ID") export const ACTUAL_SYNC_ID = getOrThrow("ACTUAL_SYNC_ID") export const ACTUAL_SERVER_URL = getOrThrow("ACTUAL_SERVER_URL") export const ACTUAL_PASSWORD = getOrThrow("ACTUAL_PASSWORD") export const ACTUAL_ACCOUNT_IDS = getArrayOrThrow("ACTUAL_ACCOUNT_IDS") export const ACTUAL_DATA_DIR = ".cache" +export const BANK_INITIAL_REFRESH_TOKEN = getOrThrow( + "BANK_INITIAL_REFRESH_TOKEN", +) export const BANK_OAUTH_CLIENT_ID = getOrThrow("BANK_OAUTH_CLIENT_ID") export const BANK_OAUTH_CLIENT_SECRET = getOrThrow("BANK_OAUTH_CLIENT_SECRET") -export const BANK_OAUTH_REDIRECT_URI = getOrThrow("BANK_OAUTH_REDIRECT_URI") -export const BANK_OAUTH_STATE = getOrThrow("BANK_OAUTH_STATE") export const BANK_ACCOUNT_IDS = getArrayOrThrow("BANK_ACCOUNT_IDS") function getOrThrow(key: string): string { diff --git a/package.json b/package.json index 263a63f..1076ecb 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@actual-app/api": "^24.12.0", "@dotenvx/dotenvx": "^1.31.3", "cron": "^3.3.1", + "dayjs": "^1.11.13", "dotenv": "^16.4.7", "pino": "^9.5.0", "prettier": "^3.4.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1cceef..1735b2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ dependencies: cron: specifier: ^3.3.1 version: 3.3.1 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 dotenv: specifier: ^16.4.7 version: 16.4.7 @@ -1401,6 +1404,10 @@ packages: engines: {node: '>= 12'} dev: false + /dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + dev: false + /debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} diff --git a/src/bank/sparebank1.ts b/src/bank/sparebank1.ts index 0f9d0de..186aac1 100644 --- a/src/bank/sparebank1.ts +++ b/src/bank/sparebank1.ts @@ -1,4 +1,12 @@ // TODO move types +import { + BANK_INITIAL_REFRESH_TOKEN, + BANK_OAUTH_CLIENT_ID, + BANK_OAUTH_CLIENT_SECRET, +} from "@/../config.ts" +import logger from "@/logger.ts" +import dayjs from "dayjs" + export interface OAuthTokenResponse { access_token: string expires_in: number @@ -8,6 +16,16 @@ export interface OAuthTokenResponse { refresh_token: string } +interface AccessToken { + access_token: string + expires_in: number +} + +interface RefreshToken { + refresh_token: string + expires_in: number +} + export interface Transaction { id: string date: string @@ -22,36 +40,102 @@ export interface Transaction { export type Bank = Sparebank1 export interface Sparebank1 { - accessToken: () => Promise - refreshToken: (refreshToken: string) => Promise - transactionsPastDay: ( accountKeys: ReadonlyArray | string, - accessToken: string, ) => Promise> } export class Sparebank1Impl implements Sparebank1 { - private baseUrl = "https://api.sparebank1.no" + private static baseUrl = "https://api.sparebank1.no" + private _accessToken: AccessToken | undefined + private _refreshToken: RefreshToken | undefined - // TODO remove? - async accessToken(): Promise { - throw new Error("Not implemented") - - // if (response.ok) { - // return await response.json() - // } - // throw new Error(`Failed to get access token. ${response.statusText}`) + private set accessToken(accessToken: AccessToken) { + this._accessToken = accessToken } - async refreshToken(refreshToken: string): Promise { - throw new Error("Not implemented") + private set refreshToken(refreshToken: RefreshToken) { + this._refreshToken = refreshToken + } + + private async getAccessToken(): Promise { + const accessToken = this._accessToken + if (!accessToken) { + const response = await this.fetchNewRefreshToken() + return response.access_token + } + return accessToken.access_token + } + + private async getRefreshToken(): Promise { + const refreshToken = this._refreshToken + // TODO check if valid, use jsonwebtoken npm library? + const isValid = true + if (!refreshToken) { + return BANK_INITIAL_REFRESH_TOKEN + } else if (isValid) { + return refreshToken.refresh_token + } else { + const response = await this.fetchNewRefreshToken() + return response.refresh_token + } + } + + async fetchNewRefreshToken(): Promise { + const refreshToken: string = await this.getRefreshToken() + const queries = new URLSearchParams({ + client_id: BANK_OAUTH_CLIENT_ID, + client_secret: BANK_OAUTH_CLIENT_SECRET, + refresh_token: refreshToken, + grant_type: "refresh_token", + }) + const response = await fetch(`${Sparebank1Impl.baseUrl}/token?${queries}`) + + if (!response.ok) { + throw new Error("Failed to fetch refresh token") + } + + const oAuthToken: OAuthTokenResponse = await response.json() + this.accessToken = { + access_token: oAuthToken.access_token, + expires_in: oAuthToken.expires_in, + } + this.refreshToken = { + refresh_token: oAuthToken.refresh_token, + expires_in: oAuthToken.refresh_token_expires_in, + } + return oAuthToken } async transactionsPastDay( accountKeys: ReadonlyArray | string, - accessToken: string, ): Promise> { - throw new Error("Not implemented") + 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: lastDay.toString(), + toDate: today.toString(), + }) + + const accessToken = await this.getAccessToken() + const response = await fetch( + `${Sparebank1Impl.baseUrl}/transactions?${queries}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + if (response.ok) { + return response.json() + } else { + logger.warn( + `transactionsPastDay returned a ${response.status} with the text ${response.statusText}`, + ) + return [] + } } } diff --git a/src/main.ts b/src/main.ts index 7925c6b..f0cbd02 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,7 @@ import { } from "@/bank/sparebank1.ts" import { bankTransactionIntoActualTransaction } from "@/mappings.ts" import { ACTUAL_ACCOUNT_IDS, BANK_ACCOUNT_IDS } from "../config.ts" -import logger from "./logger.ts" +import logger from "@/logger.ts" import type { UUID } from "node:crypto" // TODO Transports api for pino https://github.com/pinojs/pino/blob/HEAD/docs/transports.md @@ -24,22 +24,25 @@ export async function daily(actual: Actual, bank: Bank): Promise { bankTransactionIntoActualTransaction(transaction, accountId), ) - logger.debug(`Mapped ${JSON.stringify(transactions)} to ${JSON.stringify(actualTransactions)} transactions`) + logger.debug( + `Mapped ${JSON.stringify(transactions)} to ${JSON.stringify(actualTransactions)} transactions`, + ) // TODO Import transactions into Actual // If multiple accounts, loop over them // Get account ID from mapper - const response = await actual.importTransactions(accountId, actualTransactions) + const response = await actual.importTransactions( + accountId, + actualTransactions, + ) logger.info(`ImportTransactionsResponse=${JSON.stringify(response)}`) } async function fetchTransactionsFromPastDay( bank: Bank, ): Promise> { - // TODO refresh token - const { access_token } = await bank.refreshToken("my_refresh_token") - return bank.transactionsPastDay(BANK_ACCOUNT_IDS, access_token) + return bank.transactionsPastDay(BANK_ACCOUNT_IDS) } async function main(): Promise { diff --git a/tests/stubs/bankStub.ts b/tests/stubs/bankStub.ts index 2d4522e..f2b60dc 100644 --- a/tests/stubs/bankStub.ts +++ b/tests/stubs/bankStub.ts @@ -1,26 +1,8 @@ -import type { Bank, OAuthTokenResponse, Transaction } from "@/bank/sparebank1.ts" - -const tokenResponse: OAuthTokenResponse = { - access_token: "my_access_token", - token_type: "Bearer", - expires_in: 3600, - refresh_token: "my_refresh_token", - refresh_token_expires_in: 3600, - refresh_token_absolute_expires_in: 3600, -} +import type { Bank, Transaction } from "@/bank/sparebank1.ts" export class BankStub implements Bank { - async accessToken(): Promise { - return tokenResponse - } - - async refreshToken(_unused: string): Promise { - return tokenResponse - } - async transactionsPastDay( _accountIds: ReadonlyArray | string, - _accessToken: string, ): Promise> { const someFields = { date: "2019-08-20",