🚀 Daily is now Bank agnostic

- Separated createDir into a new file fs.ts
- Moved mapTransactions into Bank interface
- Take interval as input into importTransactions
This commit is contained in:
Martin Berg Alstad 2025-02-06 18:56:51 +01:00
parent 066331cca8
commit 4f05382fc4
Signed by: martials
GPG Key ID: A3824877B269F2E2
5 changed files with 100 additions and 84 deletions

View File

@ -1,10 +1,6 @@
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 {
fetchToken,
@ -12,6 +8,8 @@ import {
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 +43,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 +61,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")
@ -84,7 +101,7 @@ export class Sparebank1Impl implements Bank {
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)
@ -98,16 +115,4 @@ export class Sparebank1Impl implements Bank {
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

@ -1,72 +1,69 @@
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 if possible, capture other log messages and move them into pino
// 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)
}
}