Compare commits
2 Commits
6650e2cd2b
...
3bf354b4bf
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3bf354b4bf | ||
![]() |
8854a22b40 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
BIN
default.sqlite
Normal file
Binary file not shown.
@ -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
20
pnpm-lock.yaml
generated
@ -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
19
src/bank/db/queries.ts
Normal 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
|
||||||
|
)
|
||||||
|
}
|
@ -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
32
src/bank/sparebank1Api.ts
Normal 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())
|
||||||
|
}
|
12
src/main.ts
12
src/main.ts
@ -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()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user