From 2e73baf98b3579f2b9756e25548d0da7114f02aa Mon Sep 17 00:00:00 2001 From: Martin Berg Alstad Date: Sat, 25 Jan 2025 22:30:52 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=8F=20README,=20fix=20start-once=20bug=20?= =?UTF-8?q?and=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added some configuration and running to README - Refactored some code - Fixed exception when stopping a start-once script - Only allow running with pnpm - Moved transactions into sparebank1Api.ts - Formatted --- README.md | 25 +++++++++++++++++++++++ config.ts | 1 + jest.config.ts | 4 +--- modules.d.ts | 1 - package.json | 1 + src/bank/sparebank1.ts | 36 +++++--------------------------- src/bank/sparebank1Api.ts | 43 +++++++++++++++++++++++++++++++++++++-- src/main.ts | 22 ++++++++++++-------- tsconfig.json | 15 +++----------- 9 files changed, 91 insertions(+), 57 deletions(-) delete mode 100644 modules.d.ts diff --git a/README.md b/README.md index 250c94d..9c5161d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,28 @@ # Sparebank1 ActualBudget Integration 🔧 WIP! + +### Setting up the environment + +In order to start the application, a `.env.local` file must be present at the root level. The possible and required +fields +can be found in the [.env.example](.env.example) file and `config.ts`. + +For running integration tests, the `.env.test.local` file must be present at the root level, with Actual fields present. + +HTTP requests can be used from an IDE via the .http files. Secrets must be placed in a file called +`http-client.private.env.json` in the [httpRequests](httpRequests) directory. See the .http files for required values. + +### Running the application + +Start the application using a CronJob that runs at a given time. Can be stopped using an interrupt (^C) + +```shell +pnpm start +``` + +Start the application without a CronJob, it will run once, then shutdown. + +```shell +pnpm start-once +``` diff --git a/config.ts b/config.ts index c6a75fb..2aa793b 100644 --- a/config.ts +++ b/config.ts @@ -22,6 +22,7 @@ export const BANK_ACCOUNT_IDS = getArrayOrThrow("BANK_ACCOUNT_IDS") export const DB_FILENAME = getOrDefault("DB_FILENAME", "default") export const LOG_LEVEL = getOrDefault("LOG_LEVEL", "info") +// Utility functions function getOrDefault(key: string, def: string): string { return process.env[key] || def } diff --git a/jest.config.ts b/jest.config.ts index 1cca969..84a7b9b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -16,9 +16,7 @@ const config: JestConfigWithTsJest = { // Resolve @/ module paths "@/(.*)": "/src/$1", }, - setupFiles: [ - "/config.ts", - ] + setupFiles: ["/config.ts"], } export default config diff --git a/modules.d.ts b/modules.d.ts deleted file mode 100644 index 70b786d..0000000 --- a/modules.d.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO diff --git a/package.json b/package.json index 4dd6943..48a5ed9 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "", "main": "index.js", "scripts": { + "preinstall": "npx only-allow pnpm", "start": "dotenvx run --env-file=.env.local -- node --import=tsx ./src/main.ts | pino-pretty", "start-once": "ONCE=true dotenvx run --env-file=.env.local -- node --import=tsx ./src/main.ts | pino-pretty", "test": "dotenvx run --env-file=.env.test.local -- node --experimental-vm-modules node_modules/jest/bin/jest.js | pino-pretty", diff --git a/src/bank/sparebank1.ts b/src/bank/sparebank1.ts index 9065389..8d82f57 100644 --- a/src/bank/sparebank1.ts +++ b/src/bank/sparebank1.ts @@ -1,4 +1,3 @@ -// TODO move types import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts" import logger from "@/logger.ts" import dayjs from "dayjs" @@ -9,7 +8,6 @@ import { type TokenResponse, } from "@/bank/db/queries.ts" import * as Api from "./sparebank1Api.ts" -import { toISODateString } from "@/date.ts" export interface OAuthTokenResponse { access_token: string @@ -39,12 +37,11 @@ export type Bank = Sparebank1 export interface Sparebank1 { transactionsPastDay: ( - accountKeys: ReadonlyArray | string, + ...accountKeys: ReadonlyArray ) => Promise } export class Sparebank1Impl implements Sparebank1 { - private static baseUrl = "https://api.sparebank1.no" private readonly db: Database constructor(db: Database) { @@ -80,7 +77,6 @@ export class Sparebank1Impl implements Sparebank1 { async fetchNewTokens(): Promise { const refreshToken = await this.getRefreshToken() - logger.debug(`Found refresh token '${refreshToken}'`) const result = await Api.refreshToken(refreshToken) if (result.status === "failure") { @@ -95,35 +91,13 @@ export class Sparebank1Impl implements Sparebank1 { } async transactionsPastDay( - accountKeys: ReadonlyArray | string, + ...accountKeys: ReadonlyArray ): 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), + return await Api.transactions(await this.getAccessToken(), accountKeys, { + fromDate: lastDay, + toDate: 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: [] } - } } } diff --git a/src/bank/sparebank1Api.ts b/src/bank/sparebank1Api.ts index 13d0bc3..eeb818b 100644 --- a/src/bank/sparebank1Api.ts +++ b/src/bank/sparebank1Api.ts @@ -1,6 +1,11 @@ import { BANK_OAUTH_CLIENT_ID, BANK_OAUTH_CLIENT_SECRET } from "../../config.ts" -import { OAuthTokenResponse } from "@/bank/sparebank1.ts" +import type { + OAuthTokenResponse, + TransactionResponse, +} from "@/bank/sparebank1.ts" import logger from "@/logger.ts" +import { type Dayjs } from "dayjs" +import { toISODateString } from "@/date.ts" const baseUrl = "https://api.sparebank1.no" @@ -16,6 +21,40 @@ function failure(data: T): Failure { return { status: "failure", data: data } } +export async function transactions( + accessToken: string, + accountKeys: string | ReadonlyArray, + timePeriod?: { + fromDate: Dayjs + toDate: Dayjs + }, +): Promise { + const queries = new URLSearchParams({ + // TODO allow multiple accountKeys + accountKey: typeof accountKeys === "string" ? accountKeys : accountKeys[0], + ...(timePeriod && { + fromDate: toISODateString(timePeriod.fromDate), + toDate: toISODateString(timePeriod.toDate), + }), + }) + + const url = `${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: [] } + } +} + export async function refreshToken( refreshToken: string, ): Promise> { @@ -26,7 +65,7 @@ export async function refreshToken( grant_type: "refresh_token", }) const url = `${baseUrl}/oauth/token?${queries}` - logger.debug("Sending POST request to url: '%s'", url) + logger.debug(`Sending POST request to url: '${url}'`) const response = await fetch(url, { method: "post", headers: { diff --git a/src/main.ts b/src/main.ts index 691256f..75fd32b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import logger from "@/logger.ts" import type { UUID } from "node:crypto" import { createDb } from "@/bank/db/queries.ts" import * as fs from "node:fs" +import { CronJob } from "cron" // TODO Transports api for pino https://github.com/pinojs/pino/blob/HEAD/docs/transports.md @@ -27,12 +28,15 @@ export async function daily(actual: Actual, bank: Bank): Promise { // TODO multiple accounts const accountId = ACTUAL_ACCOUNT_IDS[0] as UUID const actualTransactions = transactions.map((transaction) => + // TODO move to Bank interface? bankTransactionIntoActualTransaction(transaction, accountId), ) - logger.debug( - `Mapped ${JSON.stringify(transactions)} to ${JSON.stringify(actualTransactions)} transactions`, - ) + logger.trace({ + aMessage: "Mapped from Bank to Actual", + from: JSON.stringify(transactions), + to: JSON.stringify(actualTransactions), + }) // TODO Import transactions into Actual // If multiple accounts, loop over them @@ -48,7 +52,7 @@ export async function daily(actual: Actual, bank: Bank): Promise { async function fetchTransactionsFromPastDay( bank: Bank, ): Promise> { - const response = await bank.transactionsPastDay(BANK_ACCOUNT_IDS) + const response = await bank.transactionsPastDay(...BANK_ACCOUNT_IDS) return response.transactions } @@ -68,22 +72,24 @@ async function main(): Promise { const databaseFileName = `${DB_FILENAME}.sqlite` const db = createDb(databaseFileName) logger.info(`Started SQLlite database with filename="${databaseFileName}"`) + const bank = new Sparebank1Impl(db) process.on("SIGINT", async () => { logger.info("Caught interrupt signal") await shutdown() }) + let cronJob: CronJob | undefined if (process.env.ONCE) { - await daily(actual, new Sparebank1Impl(db)) + await daily(actual, bank) await shutdown() return } logger.info("Waiting for CRON job to start") - const cronJob = cronJobDaily(async () => { + cronJob = cronJobDaily(async () => { logger.info("Running daily job") - await daily(actual, new Sparebank1Impl(db)) + await daily(actual, bank) logger.info("Finished daily job") }) @@ -91,7 +97,7 @@ async function main(): Promise { logger.info("Shutting down") await actual.shutdown() db.close() - cronJob.stop() + cronJob?.stop() } } diff --git a/tsconfig.json b/tsconfig.json index 50b1b3e..cb36906 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,5 @@ { - "include": [ - "./src/**/*.ts", - "./tests/**/*.ts" - ], + "include": ["./src/**/*.ts", "./tests/**/*.ts"], "compilerOptions": { "target": "esnext", "module": "ESNext", @@ -13,14 +10,8 @@ "skipLibCheck": true, "allowImportingTsExtensions": true, "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, - "exclude": [ - "node_modules", - "./*.ts", - "__test__" - ] + "exclude": ["node_modules", "./*.ts", "__test__"] }