✏ 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:
parent
4977e7ad6a
commit
2e73baf98b
25
README.md
25
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
|
||||
```
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
1
modules.d.ts
vendored
@ -1 +0,0 @@
|
||||
// TODO
|
@ -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",
|
||||
|
@ -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: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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: {
|
||||
|
22
src/main.ts
22
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<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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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__"]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user