✏ 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
🔧 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 LOG_LEVEL = getOrDefault("LOG_LEVEL", "info")
// Utility functions
function getOrDefault(key: string, def: string): string {
return process.env[key] || def
}

View File

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

1
modules.d.ts vendored
View File

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

View File

@ -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",

View File

@ -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> | string,
...accountKeys: ReadonlyArray<string>
) => Promise<TransactionResponse>
}
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<OAuthTokenResponse> {
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> | string,
...accountKeys: ReadonlyArray<string>
): Promise<TransactionResponse> {
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: [] }
}
}
}

View File

@ -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<T>(data: T): Failure<T> {
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(
refreshToken: string,
): Promise<Result<OAuthTokenResponse, string>> {
@ -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: {

View File

@ -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<void> {
// 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<void> {
async function fetchTransactionsFromPastDay(
bank: Bank,
): Promise<ReadonlyArray<Transaction>> {
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<void> {
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<void> {
logger.info("Shutting down")
await actual.shutdown()
db.close()
cronJob.stop()
cronJob?.stop()
}
}

View File

@ -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__"]
}