✏ README, fix start-once bug and refactor

- 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
This commit is contained in:
Martin Berg Alstad 2025-01-25 22:30:52 +01:00
parent 4977e7ad6a
commit 2e73baf98b
Signed by: martials
GPG Key ID: A3824877B269F2E2
9 changed files with 91 additions and 57 deletions

View File

@ -1,3 +1,28 @@
# Sparebank1 ActualBudget Integration # Sparebank1 ActualBudget Integration
🔧 WIP! 🔧 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
```

View File

@ -22,6 +22,7 @@ export const BANK_ACCOUNT_IDS = getArrayOrThrow("BANK_ACCOUNT_IDS")
export const DB_FILENAME = getOrDefault("DB_FILENAME", "default") export const DB_FILENAME = getOrDefault("DB_FILENAME", "default")
export const LOG_LEVEL = getOrDefault("LOG_LEVEL", "info") export const LOG_LEVEL = getOrDefault("LOG_LEVEL", "info")
// Utility functions
function getOrDefault(key: string, def: string): string { function getOrDefault(key: string, def: string): string {
return process.env[key] || def return process.env[key] || def
} }

View File

@ -16,9 +16,7 @@ const config: JestConfigWithTsJest = {
// Resolve @/ module paths // Resolve @/ module paths
"@/(.*)": "<rootDir>/src/$1", "@/(.*)": "<rootDir>/src/$1",
}, },
setupFiles: [ setupFiles: ["<rootDir>/config.ts"],
"<rootDir>/config.ts",
]
} }
export default config export default config

1
modules.d.ts vendored
View File

@ -1 +0,0 @@
// TODO

View File

@ -4,6 +4,7 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm",
"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 | pino-pretty",
"start-once": "ONCE=true 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", "test": "dotenvx run --env-file=.env.test.local -- node --experimental-vm-modules node_modules/jest/bin/jest.js | pino-pretty",

View File

@ -1,4 +1,3 @@
// TODO move types
import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts" import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts"
import logger from "@/logger.ts" import logger from "@/logger.ts"
import dayjs from "dayjs" import dayjs from "dayjs"
@ -9,7 +8,6 @@ import {
type TokenResponse, type TokenResponse,
} from "@/bank/db/queries.ts" } from "@/bank/db/queries.ts"
import * as Api from "./sparebank1Api.ts" import * as Api from "./sparebank1Api.ts"
import { toISODateString } from "@/date.ts"
export interface OAuthTokenResponse { export interface OAuthTokenResponse {
access_token: string access_token: string
@ -39,12 +37,11 @@ export type Bank = Sparebank1
export interface Sparebank1 { export interface Sparebank1 {
transactionsPastDay: ( transactionsPastDay: (
accountKeys: ReadonlyArray<string> | string, ...accountKeys: ReadonlyArray<string>
) => Promise<TransactionResponse> ) => Promise<TransactionResponse>
} }
export class Sparebank1Impl implements Sparebank1 { export class Sparebank1Impl implements Sparebank1 {
private static baseUrl = "https://api.sparebank1.no"
private readonly db: Database private readonly db: Database
constructor(db: Database) { constructor(db: Database) {
@ -80,7 +77,6 @@ export class Sparebank1Impl implements Sparebank1 {
async fetchNewTokens(): Promise<OAuthTokenResponse> { async fetchNewTokens(): Promise<OAuthTokenResponse> {
const refreshToken = await this.getRefreshToken() const refreshToken = await this.getRefreshToken()
logger.debug(`Found refresh token '${refreshToken}'`)
const result = await Api.refreshToken(refreshToken) const result = await Api.refreshToken(refreshToken)
if (result.status === "failure") { if (result.status === "failure") {
@ -95,35 +91,13 @@ export class Sparebank1Impl implements Sparebank1 {
} }
async transactionsPastDay( async transactionsPastDay(
accountKeys: ReadonlyArray<string> | string, ...accountKeys: ReadonlyArray<string>
): Promise<TransactionResponse> { ): Promise<TransactionResponse> {
const today = dayjs() const today = dayjs()
const lastDay = today.subtract(1, "day") const lastDay = today.subtract(1, "day")
return await Api.transactions(await this.getAccessToken(), accountKeys, {
const queries = new URLSearchParams({ fromDate: lastDay,
// TODO allow multiple accountKeys toDate: today,
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: [] }
}
} }
} }

View File

@ -1,6 +1,11 @@
import { BANK_OAUTH_CLIENT_ID, BANK_OAUTH_CLIENT_SECRET } from "../../config.ts" 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 logger from "@/logger.ts"
import { type Dayjs } from "dayjs"
import { toISODateString } from "@/date.ts"
const baseUrl = "https://api.sparebank1.no" const baseUrl = "https://api.sparebank1.no"
@ -16,6 +21,40 @@ function failure<T>(data: T): Failure<T> {
return { status: "failure", data: data } return { status: "failure", data: data }
} }
export async function transactions(
accessToken: string,
accountKeys: string | ReadonlyArray<string>,
timePeriod?: {
fromDate: Dayjs
toDate: Dayjs
},
): Promise<TransactionResponse> {
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( export async function refreshToken(
refreshToken: string, refreshToken: string,
): Promise<Result<OAuthTokenResponse, string>> { ): Promise<Result<OAuthTokenResponse, string>> {
@ -26,7 +65,7 @@ export async function refreshToken(
grant_type: "refresh_token", grant_type: "refresh_token",
}) })
const url = `${baseUrl}/oauth/token?${queries}` 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, { const response = await fetch(url, {
method: "post", method: "post",
headers: { headers: {

View File

@ -16,6 +16,7 @@ import logger from "@/logger.ts"
import type { UUID } from "node:crypto" import type { UUID } from "node:crypto"
import { createDb } from "@/bank/db/queries.ts" import { createDb } from "@/bank/db/queries.ts"
import * as fs from "node:fs" 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 // 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<void> {
// TODO multiple accounts // TODO multiple accounts
const accountId = ACTUAL_ACCOUNT_IDS[0] as UUID const accountId = ACTUAL_ACCOUNT_IDS[0] as UUID
const actualTransactions = transactions.map((transaction) => const actualTransactions = transactions.map((transaction) =>
// TODO move to Bank interface?
bankTransactionIntoActualTransaction(transaction, accountId), bankTransactionIntoActualTransaction(transaction, accountId),
) )
logger.debug( logger.trace({
`Mapped ${JSON.stringify(transactions)} to ${JSON.stringify(actualTransactions)} transactions`, aMessage: "Mapped from Bank to Actual",
) from: JSON.stringify(transactions),
to: JSON.stringify(actualTransactions),
})
// TODO Import transactions into Actual // TODO Import transactions into Actual
// If multiple accounts, loop over them // If multiple accounts, loop over them
@ -48,7 +52,7 @@ export async function daily(actual: Actual, bank: Bank): Promise<void> {
async function fetchTransactionsFromPastDay( async function fetchTransactionsFromPastDay(
bank: Bank, bank: Bank,
): Promise<ReadonlyArray<Transaction>> { ): Promise<ReadonlyArray<Transaction>> {
const response = await bank.transactionsPastDay(BANK_ACCOUNT_IDS) const response = await bank.transactionsPastDay(...BANK_ACCOUNT_IDS)
return response.transactions return response.transactions
} }
@ -68,22 +72,24 @@ async function main(): Promise<void> {
const databaseFileName = `${DB_FILENAME}.sqlite` const databaseFileName = `${DB_FILENAME}.sqlite`
const db = createDb(databaseFileName) const db = createDb(databaseFileName)
logger.info(`Started SQLlite database with filename="${databaseFileName}"`) logger.info(`Started SQLlite database with filename="${databaseFileName}"`)
const bank = new Sparebank1Impl(db)
process.on("SIGINT", async () => { process.on("SIGINT", async () => {
logger.info("Caught interrupt signal") logger.info("Caught interrupt signal")
await shutdown() await shutdown()
}) })
let cronJob: CronJob | undefined
if (process.env.ONCE) { if (process.env.ONCE) {
await daily(actual, new Sparebank1Impl(db)) await daily(actual, bank)
await shutdown() await shutdown()
return return
} }
logger.info("Waiting for CRON job to start") logger.info("Waiting for CRON job to start")
const cronJob = cronJobDaily(async () => { cronJob = cronJobDaily(async () => {
logger.info("Running daily job") logger.info("Running daily job")
await daily(actual, new Sparebank1Impl(db)) await daily(actual, bank)
logger.info("Finished daily job") logger.info("Finished daily job")
}) })
@ -91,7 +97,7 @@ async function main(): Promise<void> {
logger.info("Shutting down") logger.info("Shutting down")
await actual.shutdown() await actual.shutdown()
db.close() db.close()
cronJob.stop() cronJob?.stop()
} }
} }

View File

@ -1,8 +1,5 @@
{ {
"include": [ "include": ["./src/**/*.ts", "./tests/**/*.ts"],
"./src/**/*.ts",
"./tests/**/*.ts"
],
"compilerOptions": { "compilerOptions": {
"target": "esnext", "target": "esnext",
"module": "ESNext", "module": "ESNext",
@ -13,14 +10,8 @@
"skipLibCheck": true, "skipLibCheck": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"paths": { "paths": {
"@/*": [ "@/*": ["./src/*"]
"./src/*"
]
} }
}, },
"exclude": [ "exclude": ["node_modules", "./*.ts", "__test__"]
"node_modules",
"./*.ts",
"__test__"
]
} }