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,
refreshToken: string,
expiresIn: number,
) {
): void {
insert(db, "refresh-token", refreshToken, expiresIn)
}
@ -59,7 +59,7 @@ function insert(
key: TokenKey,
token: string,
expiresIn: number,
) {
): void {
db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run(
key,
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 {
BANK_INITIAL_REFRESH_TOKEN,
TRANSACTION_RELATIVE_FROM_DATE,
TRANSACTION_RELATIVE_TO_DATE,
} from "@/../config.ts"
import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts"
import logger from "@/logger.ts"
import dayjs from "dayjs"
import dayjs, { Dayjs } from "dayjs"
import { Database } from "better-sqlite3"
import {
clearTokens,
fetchToken,
insertTokens,
type TokenResponse,
} from "@/bank/db/queries.ts"
import * as Api from "./sparebank1Api.ts"
import { ActualTransaction } from "@/actual.ts"
import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
export interface OAuthTokenResponse {
access_token: string
@ -45,9 +44,15 @@ export interface TransactionResponse {
}
export interface Bank {
transactionsPastDay: (
fetchTransactions: (
interval: Interval,
...accountKeys: ReadonlyArray<string>
) => Promise<TransactionResponse>
) => Promise<ReadonlyArray<ActualTransaction>>
}
export interface Interval {
fromDate: Dayjs
toDate: Dayjs
}
export class Sparebank1Impl implements Bank {
@ -57,6 +62,19 @@ export class Sparebank1Impl implements Bank {
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> {
const accessToken = fetchToken(this.db, "access-token")
@ -80,34 +98,21 @@ export class Sparebank1Impl implements Bank {
} else if (this.isValidToken(tokenResponse)) {
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")
}
async fetchNewTokens(): Promise<OAuthTokenResponse> {
private async fetchNewTokens(): Promise<OAuthTokenResponse> {
const refreshToken = await this.getRefreshToken()
const result = await Api.refreshToken(refreshToken)
if (result.status === "failure") {
throw logger.error({
err: new Error(`Failed to fetch refresh token: '${result.data}'`),
})
throw new Error(`Failed to fetch refresh token: '${result.data}'`)
}
const oAuthToken = result.data
insertTokens(this.db, 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 type {
Interval,
OAuthTokenResponse,
TransactionResponse,
} from "@/bank/sparebank1.ts"
import logger from "@/logger.ts"
import { type Dayjs } from "dayjs"
import { toISODateString } from "@/date.ts"
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(
accessToken: string,
accountKeys: string | ReadonlyArray<string>,
timePeriod?: {
fromDate: Dayjs
toDate: Dayjs
},
interval?: Interval,
): Promise<TransactionResponse> {
const queryString = querystring.stringify({
accountKey: accountKeys,
...(timePeriod && {
fromDate: toISODateString(timePeriod.fromDate),
toDate: toISODateString(timePeriod.toDate),
...(interval && {
fromDate: toISODateString(interval.fromDate),
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"
*/
export default pino({
level: LOG_LEVEL,
})
const logger = pino(
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 { cronJobDaily } from "@/cron.ts"
import {
type Bank,
Sparebank1Impl,
type Transaction,
} from "@/bank/sparebank1.ts"
import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
import { type Bank, type Interval, Sparebank1Impl } from "@/bank/sparebank1.ts"
import {
ACTUAL_DATA_DIR,
BANK_ACCOUNT_IDS,
DB_DIRECTORY,
DB_FILENAME,
TRANSACTION_RELATIVE_FROM_DATE,
TRANSACTION_RELATIVE_TO_DATE,
} from "../config.ts"
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"
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 verbatimSyntax in tsconfig, conflicts with jest
// 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 do not fetch if saturday or sunday
export async function daily(actual: Actual, bank: Bank): Promise<void> {
// Fetch transactions from the bank
const transactions = await fetchTransactionsFromPastDay(bank)
logger.info(`Fetched ${transactions.length} transactions`)
const actualTransactions = transactions.map((transaction) =>
// TODO move to Bank interface?
bankTransactionIntoActualTransaction(transaction),
const actualTransactions = await bank.fetchTransactions(
relativeInterval(),
...BANK_ACCOUNT_IDS,
)
logger.info(`Fetched ${actualTransactions.length} transactions`)
const transactionsGroup = Object.groupBy(
const transactionsByAccount = Object.groupBy(
actualTransactions,
(transaction) => transaction.account,
)
const response = await Promise.all(
Object.entries(transactionsGroup).map(([accountId, transactions]) =>
const responses = await Promise.all(
Object.entries(transactionsByAccount).map(([accountId, 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(
bank: Bank,
): Promise<ReadonlyArray<Transaction>> {
const response = await bank.transactionsPastDay(...BANK_ACCOUNT_IDS)
return response.transactions
}
function createDirIfMissing(directory: string): void {
if (!fs.existsSync(directory)) {
logger.info(`Missing '${directory}', creating...`)
fs.mkdirSync(directory, { recursive: true })
function relativeInterval(): Interval {
const today = dayjs()
return {
fromDate: today.subtract(TRANSACTION_RELATIVE_FROM_DATE, "days"),
toDate: today.subtract(TRANSACTION_RELATIVE_TO_DATE, "days"),
}
}
async function main(): Promise<void> {
logger.info("Starting application")
createDirIfMissing(ACTUAL_DATA_DIR)
createDirIfMissing(DB_DIRECTORY)
createDirsIfMissing(ACTUAL_DATA_DIR, DB_DIRECTORY)
const actual = await ActualImpl.init()
const databaseFilePath = `${DB_DIRECTORY}/${DB_FILENAME}.sqlite`

View File

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