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