Compare commits

...

2 Commits

Author SHA1 Message Date
Martin Berg Alstad
3bf354b4bf
SQLite
Moved DB queries to separate file
Moved Sparebank1 API call to separate file
Create database tokens table if it doesn't exist

Signed-off-by: Martin Berg Alstad <git@martials.no>
2024-12-26 14:08:09 +01:00
Martin Berg Alstad
8854a22b40
Added better-sqlite3 dep and created default db
Signed-off-by: Martin Berg Alstad <git@martials.no>
2024-12-26 12:49:54 +01:00
8 changed files with 103 additions and 16 deletions

4
.gitignore vendored
View File

@ -174,3 +174,7 @@ dist
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
# SQLite
*.sqlite-shm
*.sqlite-wal

BIN
default.sqlite Normal file

Binary file not shown.

View File

@ -4,7 +4,7 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node --import=tsx ./src/main.ts | pino-pretty", "start": "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",
"format": "prettier --write \"./**/*.{js,mjs,ts,md,json}\"" "format": "prettier --write \"./**/*.{js,mjs,ts,md,json}\""
}, },
@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@actual-app/api": "^24.12.0", "@actual-app/api": "^24.12.0",
"@dotenvx/dotenvx": "^1.31.3", "@dotenvx/dotenvx": "^1.31.3",
"better-sqlite3": "^11.7.0",
"cron": "^3.3.1", "cron": "^3.3.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
@ -22,6 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.7.0", "@jest/globals": "^29.7.0",
"@types/better-sqlite3": "^7.6.12",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"jest": "^29.7.0", "jest": "^29.7.0",

20
pnpm-lock.yaml generated
View File

@ -11,6 +11,9 @@ dependencies:
'@dotenvx/dotenvx': '@dotenvx/dotenvx':
specifier: ^1.31.3 specifier: ^1.31.3
version: 1.31.3 version: 1.31.3
better-sqlite3:
specifier: ^11.7.0
version: 11.7.0
cron: cron:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
@ -31,6 +34,9 @@ devDependencies:
'@jest/globals': '@jest/globals':
specifier: ^29.7.0 specifier: ^29.7.0
version: 29.7.0 version: 29.7.0
'@types/better-sqlite3':
specifier: ^7.6.12
version: 7.6.12
'@types/jest': '@types/jest':
specifier: ^29.5.14 specifier: ^29.5.14
version: 29.5.14 version: 29.5.14
@ -993,6 +999,12 @@ packages:
'@babel/types': 7.26.3 '@babel/types': 7.26.3
dev: true dev: true
/@types/better-sqlite3@7.6.12:
resolution: {integrity: sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==}
dependencies:
'@types/node': 22.10.2
dev: true
/@types/graceful-fs@4.1.9: /@types/graceful-fs@4.1.9:
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
dependencies: dependencies:
@ -1193,6 +1205,14 @@ packages:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: false dev: false
/better-sqlite3@11.7.0:
resolution: {integrity: sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==}
requiresBuild: true
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.2
dev: false
/better-sqlite3@9.6.0: /better-sqlite3@9.6.0:
resolution: {integrity: sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==} resolution: {integrity: sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==}
requiresBuild: true requiresBuild: true

19
src/bank/db/queries.ts Normal file
View File

@ -0,0 +1,19 @@
import { type Database } from "better-sqlite3"
import { type OAuthTokenResponse } from "@/bank/sparebank1.ts"
const tokenKey = "sparebank1"
export function insertTokens(
db: Database,
oAuthToken: OAuthTokenResponse,
): void {
db.prepare("INSERT INTO tokens VALUES (?, ?)").run(tokenKey, oAuthToken)
}
export function fetchTokens(db: Database): OAuthTokenResponse | null {
return (
(db
.prepare("SELECT data FROM tokens WHERE key = ?")
.get(tokenKey) as OAuthTokenResponse) ?? null
)
}

View File

@ -1,11 +1,10 @@
// TODO move types // TODO move types
import { import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts"
BANK_INITIAL_REFRESH_TOKEN,
BANK_OAUTH_CLIENT_ID,
BANK_OAUTH_CLIENT_SECRET,
} from "@/../config.ts"
import logger from "@/logger.ts" import logger from "@/logger.ts"
import dayjs from "dayjs" import dayjs from "dayjs"
import { Database } from "better-sqlite3"
import { insertTokens } from "@/bank/db/queries.ts"
import * as Api from "./sparebank1Api.ts"
export interface OAuthTokenResponse { export interface OAuthTokenResponse {
access_token: string access_token: string
@ -49,6 +48,11 @@ export class Sparebank1Impl implements Sparebank1 {
private static baseUrl = "https://api.sparebank1.no" private static baseUrl = "https://api.sparebank1.no"
private _accessToken: AccessToken | undefined private _accessToken: AccessToken | undefined
private _refreshToken: RefreshToken | undefined private _refreshToken: RefreshToken | undefined
private readonly db: Database
constructor(db: Database) {
this.db = db
}
private set accessToken(accessToken: AccessToken) { private set accessToken(accessToken: AccessToken) {
this._accessToken = accessToken this._accessToken = accessToken
@ -83,19 +87,15 @@ export class Sparebank1Impl implements Sparebank1 {
async fetchNewRefreshToken(): Promise<OAuthTokenResponse> { async fetchNewRefreshToken(): Promise<OAuthTokenResponse> {
const refreshToken: string = await this.getRefreshToken() const refreshToken: string = await this.getRefreshToken()
const queries = new URLSearchParams({ const result = await Api.refreshToken(refreshToken)
client_id: BANK_OAUTH_CLIENT_ID,
client_secret: BANK_OAUTH_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: "refresh_token",
})
const response = await fetch(`${Sparebank1Impl.baseUrl}/token?${queries}`)
if (!response.ok) { if (result.status === "failure") {
throw new Error("Failed to fetch refresh token") throw new Error("Failed to fetch refresh token")
} }
const oAuthToken = result.data
insertTokens(this.db, oAuthToken)
const oAuthToken: OAuthTokenResponse = await response.json()
this.accessToken = { this.accessToken = {
access_token: oAuthToken.access_token, access_token: oAuthToken.access_token,
expires_in: oAuthToken.expires_in, expires_in: oAuthToken.expires_in,

32
src/bank/sparebank1Api.ts Normal file
View File

@ -0,0 +1,32 @@
import { BANK_OAUTH_CLIENT_ID, BANK_OAUTH_CLIENT_SECRET } from "../../config.ts"
import { OAuthTokenResponse } from "@/bank/sparebank1.ts"
const baseUrl = "https://api.sparebank1.no"
type Success<T> = { status: "success"; data: T }
type Failure<T> = { status: "failure"; data: T }
type Result<OK, Err> = Success<OK> | Failure<Err>
function success<T>(data: T): Success<T> {
return { status: "success", data: data }
}
function failure<T>(data: T): Failure<T> {
return { status: "failure", data: data }
}
export async function refreshToken(
refreshToken: string,
): Promise<Result<OAuthTokenResponse, string>> {
const queries = new URLSearchParams({
client_id: BANK_OAUTH_CLIENT_ID,
client_secret: BANK_OAUTH_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: "refresh_token",
})
const response = await fetch(`${baseUrl}/token?${queries}`)
if (!response.ok) {
return failure(response.statusText)
}
return success(await response.json())
}

View File

@ -9,6 +9,7 @@ import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
import { ACTUAL_ACCOUNT_IDS, BANK_ACCOUNT_IDS } from "../config.ts" import { ACTUAL_ACCOUNT_IDS, BANK_ACCOUNT_IDS } from "../config.ts"
import logger from "@/logger.ts" import logger from "@/logger.ts"
import type { UUID } from "node:crypto" import type { UUID } from "node:crypto"
import Database from "better-sqlite3"
// 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
// TODO create .cache if missing // TODO create .cache if missing
@ -48,16 +49,25 @@ async function fetchTransactionsFromPastDay(
async function main(): Promise<void> { async function main(): Promise<void> {
logger.info("Starting application") logger.info("Starting application")
const actual = await ActualImpl.init() const actual = await ActualImpl.init()
const databaseFileName = "default.sqlite"
const db = new Database(databaseFileName)
db.pragma("journal_mode = WAL")
db.exec(
"CREATE TABLE IF NOT EXISTS tokens (key VARCHAR PRIMARY KEY, data JSON)",
)
logger.info(`Started SQLlite database with filename="${databaseFileName}"`)
logger.info("Waiting for CRON job to start") logger.info("Waiting for CRON job to start")
cronJobDaily(async () => { cronJobDaily(async () => {
logger.info("Running daily job") logger.info("Running daily job")
await daily(actual, new Sparebank1Impl()) await daily(actual, new Sparebank1Impl(db))
logger.info("Finished daily job") logger.info("Finished daily job")
}) })
// logger.info("Shutting down") // logger.info("Shutting down")
// await actual.shutdown() // await actual.shutdown()
// db.close()
} }
void main() void main()