Compare commits

...

3 Commits

Author SHA1 Message Date
efa9e785f2
🧹 Pino transports API and capture console.log
All checks were successful
Deploy application / deploy (push) Successful in 26s
2025-02-06 19:37:11 +01:00
71e70a2713
🧹 Delete token from db if expired 2025-02-06 19:13:23 +01:00
4f05382fc4
🚀 Daily is now Bank agnostic
- Separated createDir into a new file fs.ts
- Moved mapTransactions into Bank interface
- Take interval as input into importTransactions
2025-02-06 18:56:51 +01:00
7 changed files with 123 additions and 94 deletions

View File

@ -50,7 +50,7 @@ function insertRefreshToken(
db: Database.Database, db: Database.Database,
refreshToken: string, refreshToken: string,
expiresIn: number, expiresIn: number,
) { ): void {
insert(db, "refresh-token", refreshToken, expiresIn) insert(db, "refresh-token", refreshToken, expiresIn)
} }
@ -59,7 +59,7 @@ function insert(
key: TokenKey, key: TokenKey,
token: string, token: string,
expiresIn: number, expiresIn: number,
) { ): void {
db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run( db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run(
key, key,
token, token,
@ -82,3 +82,10 @@ export function fetchToken(
} }
) )
} }
export function clearTokens(db: Database.Database): void {
db.prepare("DELETE FROM tokens WHERE key in ( ?, ? )").run([
"access-token",
"refresh-token",
] as TokenKey[])
}

View File

@ -1,17 +1,16 @@
import { import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts"
BANK_INITIAL_REFRESH_TOKEN,
TRANSACTION_RELATIVE_FROM_DATE,
TRANSACTION_RELATIVE_TO_DATE,
} from "@/../config.ts"
import logger from "@/logger.ts" import logger from "@/logger.ts"
import dayjs from "dayjs" import dayjs, { Dayjs } from "dayjs"
import { Database } from "better-sqlite3" import { Database } from "better-sqlite3"
import { import {
clearTokens,
fetchToken, fetchToken,
insertTokens, insertTokens,
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 { ActualTransaction } from "@/actual.ts"
import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
export interface OAuthTokenResponse { export interface OAuthTokenResponse {
access_token: string access_token: string
@ -45,9 +44,15 @@ export interface TransactionResponse {
} }
export interface Bank { export interface Bank {
transactionsPastDay: ( fetchTransactions: (
interval: Interval,
...accountKeys: ReadonlyArray<string> ...accountKeys: ReadonlyArray<string>
) => Promise<TransactionResponse> ) => Promise<ReadonlyArray<ActualTransaction>>
}
export interface Interval {
fromDate: Dayjs
toDate: Dayjs
} }
export class Sparebank1Impl implements Bank { export class Sparebank1Impl implements Bank {
@ -57,6 +62,19 @@ export class Sparebank1Impl implements Bank {
this.db = db this.db = db
} }
async fetchTransactions(
interval: Interval,
...accountKeys: ReadonlyArray<string>
): Promise<ReadonlyArray<ActualTransaction>> {
const response = await Api.transactions(
await this.getAccessToken(),
accountKeys,
interval,
)
const sparebankTransactions = response.transactions
return sparebankTransactions.map(bankTransactionIntoActualTransaction)
}
private async getAccessToken(): Promise<string> { private async getAccessToken(): Promise<string> {
const accessToken = fetchToken(this.db, "access-token") const accessToken = fetchToken(this.db, "access-token")
@ -80,34 +98,21 @@ export class Sparebank1Impl implements Bank {
} else if (this.isValidToken(tokenResponse)) { } else if (this.isValidToken(tokenResponse)) {
return tokenResponse.token return tokenResponse.token
} }
// TODO clear database, if refresh token is invalid, will cause Exceptions on each call logger.warn("Refresh token expired, deleting tokens from database")
clearTokens(this.db)
throw new Error("Refresh token is expired. Create a new one") throw new Error("Refresh token is expired. Create a new one")
} }
async fetchNewTokens(): Promise<OAuthTokenResponse> { private async fetchNewTokens(): Promise<OAuthTokenResponse> {
const refreshToken = await this.getRefreshToken() const refreshToken = await this.getRefreshToken()
const result = await Api.refreshToken(refreshToken) const result = await Api.refreshToken(refreshToken)
if (result.status === "failure") { if (result.status === "failure") {
throw logger.error({ throw new Error(`Failed to fetch refresh token: '${result.data}'`)
err: new Error(`Failed to fetch refresh token: '${result.data}'`),
})
} }
const oAuthToken = result.data const oAuthToken = result.data
insertTokens(this.db, oAuthToken) insertTokens(this.db, oAuthToken)
return oAuthToken return oAuthToken
} }
async transactionsPastDay(
...accountKeys: ReadonlyArray<string>
): Promise<TransactionResponse> {
const today = dayjs()
const fromDate = today.subtract(TRANSACTION_RELATIVE_FROM_DATE, "days")
const toDate = today.subtract(TRANSACTION_RELATIVE_TO_DATE, "days")
return await Api.transactions(await this.getAccessToken(), accountKeys, {
fromDate,
toDate,
})
}
} }

View File

@ -1,10 +1,10 @@
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 type { import type {
Interval,
OAuthTokenResponse, OAuthTokenResponse,
TransactionResponse, TransactionResponse,
} from "@/bank/sparebank1.ts" } from "@/bank/sparebank1.ts"
import logger from "@/logger.ts" import logger from "@/logger.ts"
import { type Dayjs } from "dayjs"
import { toISODateString } from "@/date.ts" import { toISODateString } from "@/date.ts"
import * as querystring from "node:querystring" import * as querystring from "node:querystring"
@ -20,16 +20,13 @@ const failure = <T>(data: T): Failure<T> => ({ status: "failure", data: data })
export async function transactions( export async function transactions(
accessToken: string, accessToken: string,
accountKeys: string | ReadonlyArray<string>, accountKeys: string | ReadonlyArray<string>,
timePeriod?: { interval?: Interval,
fromDate: Dayjs
toDate: Dayjs
},
): Promise<TransactionResponse> { ): Promise<TransactionResponse> {
const queryString = querystring.stringify({ const queryString = querystring.stringify({
accountKey: accountKeys, accountKey: accountKeys,
...(timePeriod && { ...(interval && {
fromDate: toISODateString(timePeriod.fromDate), fromDate: toISODateString(interval.fromDate),
toDate: toISODateString(timePeriod.toDate), toDate: toISODateString(interval.toDate),
}), }),
}) })

13
src/fs.ts Normal file
View File

@ -0,0 +1,13 @@
import * as fs from "node:fs"
import logger from "./logger"
export function createDirsIfMissing(...directories: string[]): void {
directories.forEach(createDirIfMissing)
}
export function createDirIfMissing(directory: string): void {
if (!fs.existsSync(directory)) {
logger.info(`Missing '${directory}', creating...`)
fs.mkdirSync(directory, { recursive: true })
}
}

View File

@ -4,6 +4,14 @@ import { LOG_LEVEL } from "../config.ts"
/** /**
* / Returns a logging instance with the default log-level "info" * / Returns a logging instance with the default log-level "info"
*/ */
export default pino({ const logger = pino(
level: LOG_LEVEL, pino.destination({
}) level: LOG_LEVEL,
}),
)
console.log = function (...args): void {
logger.info(args, args?.[0])
}
export default logger

View File

@ -1,72 +1,67 @@
import { type Actual, ActualImpl } from "@/actual.ts" import { type Actual, ActualImpl } from "@/actual.ts"
import { cronJobDaily } from "@/cron.ts" import { cronJobDaily } from "@/cron.ts"
import { import { type Bank, type Interval, Sparebank1Impl } from "@/bank/sparebank1.ts"
type Bank,
Sparebank1Impl,
type Transaction,
} from "@/bank/sparebank1.ts"
import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
import { import {
ACTUAL_DATA_DIR, ACTUAL_DATA_DIR,
BANK_ACCOUNT_IDS, BANK_ACCOUNT_IDS,
DB_DIRECTORY, DB_DIRECTORY,
DB_FILENAME, DB_FILENAME,
TRANSACTION_RELATIVE_FROM_DATE,
TRANSACTION_RELATIVE_TO_DATE,
} from "../config.ts" } 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 { createDb } from "@/bank/db/queries.ts" import { createDb } from "@/bank/db/queries.ts"
import * as fs from "node:fs"
import { CronJob } from "cron" import { CronJob } from "cron"
import { createDirsIfMissing } from "@/fs.ts"
import dayjs from "dayjs"
// TODO Transports api for pino https://github.com/pinojs/pino/blob/HEAD/docs/transports.md
// TODO move tsx to devDependency. Requires ts support for Node with support for @ alias // TODO move tsx to devDependency. Requires ts support for Node with support for @ alias
// TODO verbatimSyntax in tsconfig, conflicts with jest // TODO verbatimSyntax in tsconfig, conflicts with jest
// TODO multi module project. Main | DAL | Sparebank1 impl // TODO multi module project. Main | DAL | Sparebank1 impl
// TODO store last fetched date in db, and refetch from that date, if app has been offline for some time // TODO store last fetched date in db, and refetch from that date, if app has been offline for some time
// TODO do not fetch if saturday or sunday
export async function daily(actual: Actual, bank: Bank): Promise<void> { export async function daily(actual: Actual, bank: Bank): Promise<void> {
// Fetch transactions from the bank // Fetch transactions from the bank
const transactions = await fetchTransactionsFromPastDay(bank) const actualTransactions = await bank.fetchTransactions(
logger.info(`Fetched ${transactions.length} transactions`) relativeInterval(),
...BANK_ACCOUNT_IDS,
const actualTransactions = transactions.map((transaction) =>
// TODO move to Bank interface?
bankTransactionIntoActualTransaction(transaction),
) )
logger.info(`Fetched ${actualTransactions.length} transactions`)
const transactionsGroup = Object.groupBy( const transactionsByAccount = Object.groupBy(
actualTransactions, actualTransactions,
(transaction) => transaction.account, (transaction) => transaction.account,
) )
const response = await Promise.all( const responses = await Promise.all(
Object.entries(transactionsGroup).map(([accountId, transactions]) => Object.entries(transactionsByAccount).map(([accountId, transactions]) =>
actual.importTransactions(accountId as UUID, transactions || []), actual.importTransactions(accountId as UUID, transactions || []),
), ),
) )
logger.debug(response, "Finished importing transactions") logger.debug(
responses.map((response) => ({
added: response.added,
updated: response.updated,
})),
"Finished importing transactions",
)
} }
async function fetchTransactionsFromPastDay( function relativeInterval(): Interval {
bank: Bank, const today = dayjs()
): Promise<ReadonlyArray<Transaction>> { return {
const response = await bank.transactionsPastDay(...BANK_ACCOUNT_IDS) fromDate: today.subtract(TRANSACTION_RELATIVE_FROM_DATE, "days"),
return response.transactions toDate: today.subtract(TRANSACTION_RELATIVE_TO_DATE, "days"),
}
function createDirIfMissing(directory: string): void {
if (!fs.existsSync(directory)) {
logger.info(`Missing '${directory}', creating...`)
fs.mkdirSync(directory, { recursive: true })
} }
} }
async function main(): Promise<void> { async function main(): Promise<void> {
logger.info("Starting application") logger.info("Starting application")
createDirIfMissing(ACTUAL_DATA_DIR) createDirsIfMissing(ACTUAL_DATA_DIR, DB_DIRECTORY)
createDirIfMissing(DB_DIRECTORY)
const actual = await ActualImpl.init() const actual = await ActualImpl.init()
const databaseFilePath = `${DB_DIRECTORY}/${DB_FILENAME}.sqlite` const databaseFilePath = `${DB_DIRECTORY}/${DB_FILENAME}.sqlite`

View File

@ -1,41 +1,45 @@
import type { import type {
Bank, Bank,
BookingStatus, BookingStatus,
TransactionResponse, Interval,
Transaction,
} from "@/bank/sparebank1.ts" } from "@/bank/sparebank1.ts"
import dayjs from "dayjs" import dayjs from "dayjs"
import { ActualTransaction } from "@/actual.ts"
import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
export class BankStub implements Bank { export class BankStub implements Bank {
async transactionsPastDay( async fetchTransactions(
_interval: Interval,
_accountIds: ReadonlyArray<string> | string, _accountIds: ReadonlyArray<string> | string,
): Promise<TransactionResponse> { ): Promise<ReadonlyArray<ActualTransaction>> {
const someFields = { const someFields = {
date: dayjs("2019-08-20").unix(), date: dayjs("2019-08-20").unix(),
cleanedDescription: "Test transaction", cleanedDescription: "Test transaction",
remoteAccountName: "Test account", remoteAccountName: "Test account",
bookingStatus: "BOOKED" as BookingStatus, bookingStatus: "BOOKED" as BookingStatus,
accountKey: "1",
} }
return { const bankTransactions: ReadonlyArray<Transaction> = [
transactions: [ {
{ id: "1",
id: "1", nonUniqueId: "1",
nonUniqueId: "1", amount: 100,
amount: 100, ...someFields,
...someFields, },
}, {
{ id: "2",
id: "2", nonUniqueId: "2",
nonUniqueId: "2", amount: 200,
amount: 200, ...someFields,
...someFields, },
}, {
{ id: "3",
id: "3", nonUniqueId: "3",
nonUniqueId: "3", amount: -50,
amount: -50, ...someFields,
...someFields, },
}, ]
], return bankTransactions.map(bankTransactionIntoActualTransaction)
}
} }
} }