diff --git a/.dockerignore b/.dockerignore index 89fbfbb..6a712e3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,7 @@ httpRequests .env.* *.sqlite jest.config.ts -node_modules +**/node_modules .git .gitignore *.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6c73068..e14ee19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,14 @@ FROM node:22-slim LABEL authors="Martin Berg Alstad" -COPY . . +COPY . ./app +WORKDIR ./app -RUN --mount=type=cache,id=npm,target=/store npm install --omit=dev --frozen-lockfile +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable pnpm +RUN corepack prepare pnpm@9.15.3 --activate +RUN npm i -g corepack@latest +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --recursive --frozen-lockfile -ENTRYPOINT ["npm", "run", "start-prod"] \ No newline at end of file +ENTRYPOINT ["pnpm", "start-prod"] \ No newline at end of file diff --git a/README.md b/README.md index 1876f5d..3db4374 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # 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 +In order to start the application, an `.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`. diff --git a/package.json b/package.json index c3400bc..42a629e 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,11 @@ "name": "sparebank1_actual_budget_integration", "version": "1.0.0", "description": "", - "main": "index.js", "scripts": { - "start": "dotenvx run --env-file=.env.local -- node --import=tsx ./src/main.ts | pino-pretty", + "start": "dotenvx run --env-file=.env.local -- node --import=tsx ./src/main.ts | ./packages/common/node_modules/pino-pretty/bin.js", "start-prod": "node --import=tsx ./src/main.ts", - "run-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", + "run-once": "ONCE=true dotenvx run --env-file=.env.local -- node --import=tsx ./src/main.ts | ./packages/common/node_modules/pino-pretty/bin.js", + "test": "dotenvx run --env-file=.env.test.local -- node --experimental-vm-modules node_modules/jest/bin/jest.js | ./packages/common/node_modules/pino-pretty/bin.js", "docker-build": "DB_DIRECTORY=data docker compose --env-file .env.local up -d --build", "format": "prettier --write \"./**/*.{js,mjs,ts,md,json}\"" }, @@ -21,7 +20,6 @@ "cron": "^3.5.0", "dayjs": "^1.11.13", "dotenv": "^16.4.7", - "pino": "^9.6.0", "prettier": "^3.4.2", "tsx": "^4.19.2" }, @@ -31,7 +29,6 @@ "@types/jest": "^29.5.14", "@types/node": "^22.10.7", "jest": "^29.7.0", - "pino-pretty": "^13.0.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^5.7.3" diff --git a/src/date.ts b/packages/common/date.ts similarity index 100% rename from src/date.ts rename to packages/common/date.ts diff --git a/src/logger.ts b/packages/common/logger.ts similarity index 61% rename from src/logger.ts rename to packages/common/logger.ts index 3cb23f4..664eb74 100644 --- a/src/logger.ts +++ b/packages/common/logger.ts @@ -1,5 +1,5 @@ import pino from "pino" -import { LOG_LEVEL } from "../config.ts" +import { LOG_LEVEL } from "@/config.ts" /** * / Returns a logging instance with the default log-level "info" @@ -11,7 +11,11 @@ const logger = pino( ) console.log = function (...args): void { - logger.info(args, args?.[0]) + if (args.length > 1) { + logger.info(args?.slice(1), args?.[0]) + } else { + logger.info(args?.[0]) + } } export default logger diff --git a/packages/common/package.json b/packages/common/package.json new file mode 100644 index 0000000..a0d7dbd --- /dev/null +++ b/packages/common/package.json @@ -0,0 +1,12 @@ +{ + "name": "common", + "version": "1.0.0", + "description": "", + "license": "ISC", + "dependencies": { + "pino": "^9.6.0" + }, + "devDependencies": { + "pino-pretty": "^13.0.0" + } +} diff --git a/packages/sparebank1Api/common.ts b/packages/sparebank1Api/common.ts new file mode 100644 index 0000000..701d1d9 --- /dev/null +++ b/packages/sparebank1Api/common.ts @@ -0,0 +1,12 @@ +import type { Failure, Success } from "./types" + +export const baseUrl = "https://api.sparebank1.no" + +export const success = (data: T): Success => ({ + status: "success", + data: data, +}) +export const failure = (data: T): Failure => ({ + status: "failure", + data: data, +}) diff --git a/packages/sparebank1Api/oauth.ts b/packages/sparebank1Api/oauth.ts new file mode 100644 index 0000000..5067324 --- /dev/null +++ b/packages/sparebank1Api/oauth.ts @@ -0,0 +1,30 @@ +import { OAuthTokenResponse, Result } from "./types" +import * as querystring from "node:querystring" +import { baseUrl, failure, success } from "./common" +import logger from "@common/logger" + +export async function refreshToken( + clientId: string, + clientSecret: string, + refreshToken: string, +): Promise> { + const queries = querystring.stringify({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }) + const url = `${baseUrl}/oauth/token?${queries}` + logger.debug(`Sending POST request to url: '${url}'`) + const response = await fetch(url, { + method: "post", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + logger.debug(`Received response with status '${response.status}'`) + if (!response.ok) { + return failure(await response.text()) + } + return success(await response.json()) +} diff --git a/packages/sparebank1Api/package.json b/packages/sparebank1Api/package.json new file mode 100644 index 0000000..1331aba --- /dev/null +++ b/packages/sparebank1Api/package.json @@ -0,0 +1,6 @@ +{ + "name": "packages", + "version": "1.0.0", + "description": "", + "license": "ISC" +} diff --git a/packages/sparebank1Api/transactions.ts b/packages/sparebank1Api/transactions.ts new file mode 100644 index 0000000..2825853 --- /dev/null +++ b/packages/sparebank1Api/transactions.ts @@ -0,0 +1,35 @@ +import type { Interval, TransactionResponse } from "./types" +import * as querystring from "node:querystring" +import { toISODateString } from "@common/date" +import logger from "@common/logger" +import { baseUrl } from "./common" + +export async function list( + accessToken: string, + accountKeys: string | ReadonlyArray, + interval?: Interval, +): Promise { + const queryString = querystring.stringify({ + accountKey: accountKeys, + ...(interval && { + fromDate: toISODateString(interval.fromDate), + toDate: toISODateString(interval.toDate), + }), + }) + + const url = `${baseUrl}/personal/banking/transactions?${queryString}` + 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/packages/sparebank1Api/types.ts b/packages/sparebank1Api/types.ts new file mode 100644 index 0000000..4322c7d --- /dev/null +++ b/packages/sparebank1Api/types.ts @@ -0,0 +1,43 @@ +import type { Dayjs } from "dayjs" + +export type Success = { status: "success"; data: T } +export type Failure = { status: "failure"; data: T } +export type Result = Success | Failure + +export interface Interval { + fromDate: Dayjs + toDate: Dayjs +} + +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 type BookingStatus = "PENDING" | "BOOKED" + +export type NonUniqueId = "000000000000000000" | `${number}` + +export interface SB1Transaction { + id: string + nonUniqueId: NonUniqueId + // The Id of the account + accountKey: string + // Unix time + date: number + // Amount in NOK + amount: number + cleanedDescription: string + remoteAccountName: string + bookingStatus: BookingStatus + + [key: string]: string | number | boolean | unknown +} + +export interface TransactionResponse { + transactions: ReadonlyArray +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8afb873..f9f71ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: dotenv: specifier: ^16.4.7 version: 16.4.7 - pino: - specifier: ^9.6.0 - version: 9.6.0 prettier: specifier: ^3.4.2 version: 3.4.2 @@ -51,9 +48,6 @@ importers: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)) - pino-pretty: - specifier: ^13.0.0 - version: 13.0.0 ts-jest: specifier: ^29.2.5 version: 29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@22.10.7)(ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3)))(typescript@5.7.3) @@ -64,6 +58,18 @@ importers: specifier: ^5.7.3 version: 5.7.3 + packages/common: + dependencies: + pino: + specifier: ^9.6.0 + version: 9.6.0 + devDependencies: + pino-pretty: + specifier: ^13.0.0 + version: 13.0.0 + + packages/sparebank1Api: {} + packages: '@actual-app/api@25.1.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/src/actual.ts b/src/actual.ts index 275562b..8bb8a13 100644 --- a/src/actual.ts +++ b/src/actual.ts @@ -4,10 +4,10 @@ import { ACTUAL_PASSWORD, ACTUAL_SERVER_URL, ACTUAL_SYNC_ID, -} from "../config.ts" +} from "@/config.ts" import type { TransactionEntity } from "@actual-app/api/@types/loot-core/types/models" import { type UUID } from "node:crypto" -import logger from "@/logger.ts" +import logger from "@common/logger.ts" export interface Actual { importTransactions: ( diff --git a/src/bank/db/queries.ts b/src/bank/db/queries.ts index 3ce5d76..72faa99 100644 --- a/src/bank/db/queries.ts +++ b/src/bank/db/queries.ts @@ -1,7 +1,7 @@ import Database from "better-sqlite3" -import { type OAuthTokenResponse } from "@/bank/sparebank1.ts" import dayjs, { type Dayjs } from "dayjs" +import type { OAuthTokenResponse } from "@sb1/types.ts" export type TokenResponse = { key: TokenKey @@ -9,10 +9,8 @@ export type TokenResponse = { expires_at: Dayjs } -export type TokenResponseRaw = { - key: TokenResponse["key"] - token: TokenResponse["token"] - expires_at: string +type TokenResponseRaw = { + [K in keyof TokenResponse]: K extends "expires_at" ? string : TokenResponse[K] } export type TokenKey = "access-token" | "refresh-token" diff --git a/src/bank/sparebank1.ts b/src/bank/sparebank1.ts index 7dfaa00..f7c01f6 100644 --- a/src/bank/sparebank1.ts +++ b/src/bank/sparebank1.ts @@ -1,47 +1,22 @@ -import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts" -import logger from "@/logger.ts" -import dayjs, { Dayjs } from "dayjs" -import { Database } from "better-sqlite3" +import { + BANK_INITIAL_REFRESH_TOKEN, + BANK_OAUTH_CLIENT_ID, + BANK_OAUTH_CLIENT_SECRET, +} from "@/config.ts" +import logger from "@common/logger.ts" +import dayjs, { type Dayjs } from "dayjs" +import type { Database } from "better-sqlite3" import { clearTokens, fetchToken, insertTokens, type TokenResponse, } from "@/bank/db/queries.ts" -import * as Api from "./sparebank1Api.ts" -import { ActualTransaction } from "@/actual.ts" +import * as Oauth from "@sb1/oauth.ts" +import * as Transactions from "@sb1/transactions.ts" +import type { ActualTransaction } from "@/actual.ts" import { bankTransactionIntoActualTransaction } from "@/mappings.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 type BookingStatus = "PENDING" | "BOOKED" - -export interface Transaction { - id: string - nonUniqueId: string - // The Id of the account - accountKey: string - // Unix time - date: number - // Amount in NOK - amount: number - cleanedDescription: string - remoteAccountName: string - bookingStatus: BookingStatus - - [key: string]: string | number | boolean | unknown -} - -export interface TransactionResponse { - transactions: ReadonlyArray -} +import type { OAuthTokenResponse } from "@sb1/types.ts" export interface Bank { fetchTransactions: ( @@ -66,7 +41,7 @@ export class Sparebank1Impl implements Bank { interval: Interval, ...accountKeys: ReadonlyArray ): Promise> { - const response = await Api.transactions( + const response = await Transactions.list( await this.getAccessToken(), accountKeys, interval, @@ -105,7 +80,11 @@ export class Sparebank1Impl implements Bank { private async fetchNewTokens(): Promise { const refreshToken = await this.getRefreshToken() - const result = await Api.refreshToken(refreshToken) + const result = await Oauth.refreshToken( + BANK_OAUTH_CLIENT_ID, + BANK_OAUTH_CLIENT_SECRET, + refreshToken, + ) if (result.status === "failure") { throw new Error(`Failed to fetch refresh token: '${result.data}'`) diff --git a/src/bank/sparebank1Api.ts b/src/bank/sparebank1Api.ts deleted file mode 100644 index 1384cc6..0000000 --- a/src/bank/sparebank1Api.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { BANK_OAUTH_CLIENT_ID, BANK_OAUTH_CLIENT_SECRET } from "@/../config.ts" -import type { - Interval, - OAuthTokenResponse, - TransactionResponse, -} from "@/bank/sparebank1.ts" -import logger from "@/logger.ts" -import { toISODateString } from "@/date.ts" -import * as querystring from "node:querystring" - -const baseUrl = "https://api.sparebank1.no" - -type Success = { status: "success"; data: T } -type Failure = { status: "failure"; data: T } -type Result = Success | Failure - -const success = (data: T): Success => ({ status: "success", data: data }) -const failure = (data: T): Failure => ({ status: "failure", data: data }) - -export async function transactions( - accessToken: string, - accountKeys: string | ReadonlyArray, - interval?: Interval, -): Promise { - const queryString = querystring.stringify({ - accountKey: accountKeys, - ...(interval && { - fromDate: toISODateString(interval.fromDate), - toDate: toISODateString(interval.toDate), - }), - }) - - const url = `${baseUrl}/personal/banking/transactions?${queryString}` - 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> { - const queries = querystring.stringify({ - client_id: BANK_OAUTH_CLIENT_ID, - client_secret: BANK_OAUTH_CLIENT_SECRET, - refresh_token: refreshToken, - grant_type: "refresh_token", - }) - const url = `${baseUrl}/oauth/token?${queries}` - logger.debug(`Sending POST request to url: '${url}'`) - const response = await fetch(url, { - method: "post", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }) - logger.debug(`Received response with status '${response.status}'`) - if (!response.ok) { - return failure(await response.text()) - } - return success(await response.json()) -} diff --git a/config.ts b/src/config.ts similarity index 98% rename from config.ts rename to src/config.ts index 9f4f0d4..f517d2a 100644 --- a/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ export const BANK_ACCOUNT_IDS = getArrayOrThrow("BANK_ACCOUNT_IDS") export const DB_DIRECTORY = getOrDefault("DB_DIRECTORY", "data") export const DB_FILENAME = getOrDefault("DB_FILENAME", "default") export const LOG_LEVEL = getOrDefault("LOG_LEVEL", "info") +// TODO default to fetch 1 day // Relative number of days in the past to start fetching transactions from export const TRANSACTION_RELATIVE_FROM_DATE = getNumberOrDefault( "TRANSACTION_RELATIVE_FROM_DATE", diff --git a/src/cron.ts b/src/cron.ts index f046f06..eab8af4 100644 --- a/src/cron.ts +++ b/src/cron.ts @@ -1,5 +1,5 @@ import { CronJob } from "cron" -import logger from "@/logger.ts" +import logger from "@common/logger.ts" /** * Run a function every day at 1 AM, Oslo time. diff --git a/src/fs.ts b/src/fs.ts index 536fbe0..790c71f 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,5 +1,5 @@ import * as fs from "node:fs" -import logger from "./logger" +import logger from "@common/logger" export function createDirsIfMissing(...directories: string[]): void { directories.forEach(createDirIfMissing) diff --git a/src/main.ts b/src/main.ts index 1724271..a9a3a67 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,8 +8,8 @@ import { DB_FILENAME, TRANSACTION_RELATIVE_FROM_DATE, TRANSACTION_RELATIVE_TO_DATE, -} from "../config.ts" -import logger from "@/logger.ts" +} from "@/config.ts" +import logger from "@common/logger.ts" import type { UUID } from "node:crypto" import { createDb } from "@/bank/db/queries.ts" import { CronJob } from "cron" diff --git a/src/mappings.ts b/src/mappings.ts index 03108a9..cab08c6 100644 --- a/src/mappings.ts +++ b/src/mappings.ts @@ -1,13 +1,13 @@ -import type { Transaction } from "@/bank/sparebank1.ts" import type { UUID } from "node:crypto" import dayjs from "dayjs" -import { toISODateString } from "@/date.ts" import { type ActualTransaction } from "@/actual.ts" -import { ACTUAL_ACCOUNT_IDS, BANK_ACCOUNT_IDS } from "../config.ts" -import logger from "@/logger.ts" +import { ACTUAL_ACCOUNT_IDS, BANK_ACCOUNT_IDS } from "@/config.ts" +import logger from "@common/logger.ts" +import { toISODateString } from "@common/date.ts" +import type { SB1Transaction } from "@sb1/types.ts" export function bankTransactionIntoActualTransaction( - transaction: Transaction, + transaction: SB1Transaction, ): ActualTransaction { return { id: transaction.id, @@ -23,12 +23,12 @@ export function bankTransactionIntoActualTransaction( } } -export function isCleared(transaction: Transaction): boolean { +export function isCleared(transaction: SB1Transaction): boolean { const id = Number(transaction.nonUniqueId) return transaction.bookingStatus === "BOOKED" && !Number.isNaN(id) && id > 0 } -function getActualAccountId(transcation: Transaction): UUID { +function getActualAccountId(transcation: SB1Transaction): UUID { for ( let i = 0; i < Math.min(ACTUAL_ACCOUNT_IDS.length, BANK_ACCOUNT_IDS.length); diff --git a/tests/stubs/bankStub.ts b/tests/stubs/bankStub.ts index b601fc9..a3db32b 100644 --- a/tests/stubs/bankStub.ts +++ b/tests/stubs/bankStub.ts @@ -1,12 +1,8 @@ -import type { - Bank, - BookingStatus, - Interval, - Transaction, -} from "@/bank/sparebank1.ts" +import type { Bank, Interval } from "@/bank/sparebank1.ts" import dayjs from "dayjs" -import { ActualTransaction } from "@/actual.ts" +import type { ActualTransaction } from "@/actual.ts" import { bankTransactionIntoActualTransaction } from "@/mappings.ts" +import type { BookingStatus, SB1Transaction } from "@sb1/types.ts" export class BankStub implements Bank { async fetchTransactions( @@ -20,7 +16,7 @@ export class BankStub implements Bank { bookingStatus: "BOOKED" as BookingStatus, accountKey: "1", } - const bankTransactions: ReadonlyArray = [ + const bankTransactions: ReadonlyArray = [ { id: "1", nonUniqueId: "1", diff --git a/tsconfig.json b/tsconfig.json index cb36906..ad3d7f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["./src/**/*.ts", "./tests/**/*.ts"], + "include": ["./src/**/*.ts", "./packages/**/*.ts", "./tests/**/*.ts"], "compilerOptions": { "target": "esnext", "module": "ESNext", @@ -9,8 +9,11 @@ "strict": true, "skipLibCheck": true, "allowImportingTsExtensions": true, + "noEmit": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@common/*": ["./packages/common/*"], + "@sb1/*": ["./packages/sparebank1Api/*"] } }, "exclude": ["node_modules", "./*.ts", "__test__"]