diff --git a/src/bank/sparebank1.ts b/src/bank/sparebank1.ts index db7fb57..6b19f30 100644 --- a/src/bank/sparebank1.ts +++ b/src/bank/sparebank1.ts @@ -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 - ) => Promise + ) => Promise> +} + +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 + ): Promise> { + const response = await Api.transactions( + await this.getAccessToken(), + accountKeys, + interval, + ) + const sparebankTransactions = response.transactions + return sparebankTransactions.map(bankTransactionIntoActualTransaction) + } + private async getAccessToken(): Promise { 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 { + private async fetchNewTokens(): Promise { 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 - ): Promise { - 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, - }) - } } diff --git a/src/bank/sparebank1Api.ts b/src/bank/sparebank1Api.ts index b1eedb0..1384cc6 100644 --- a/src/bank/sparebank1Api.ts +++ b/src/bank/sparebank1Api.ts @@ -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 = (data: T): Failure => ({ status: "failure", data: data }) export async function transactions( accessToken: string, accountKeys: string | ReadonlyArray, - timePeriod?: { - fromDate: Dayjs - toDate: Dayjs - }, + interval?: Interval, ): Promise { const queryString = querystring.stringify({ accountKey: accountKeys, - ...(timePeriod && { - fromDate: toISODateString(timePeriod.fromDate), - toDate: toISODateString(timePeriod.toDate), + ...(interval && { + fromDate: toISODateString(interval.fromDate), + toDate: toISODateString(interval.toDate), }), }) diff --git a/src/fs.ts b/src/fs.ts new file mode 100644 index 0000000..536fbe0 --- /dev/null +++ b/src/fs.ts @@ -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 }) + } +} diff --git a/src/main.ts b/src/main.ts index 75d6ed1..a92ab5d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { // 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> { - 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 { 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` diff --git a/tests/stubs/bankStub.ts b/tests/stubs/bankStub.ts index 847e135..b601fc9 100644 --- a/tests/stubs/bankStub.ts +++ b/tests/stubs/bankStub.ts @@ -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, - ): Promise { + ): Promise> { 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 = [ + { + 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) } }