diff --git a/docker-compose.yml b/docker-compose.yml index bb6a8ae..360ad0d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: server: container_name: actual_sparebank1_cronjob - restart: no + restart: unless-stopped build: context: . environment: @@ -16,7 +16,7 @@ services: - BANK_OAUTH_CLIENT_SECRET - BANK_ACCOUNT_IDS - LOG_LEVEL - - DB_DIRECTORY # Required for Docker Compose + - DB_DIRECTORY - DB_FILENAME - TRANSACTION_RELATIVE_FROM_DATE - TRANSACTION_RELATIVE_TO_DATE @@ -24,6 +24,7 @@ services: - cache:/${ACTUAL_DATA_DIR:-.cache} - data:/${DB_DIRECTORY:-data} +# TODO change volume name from hostexecutor-* volumes: cache: data: diff --git a/packages/common/types.ts b/packages/common/types.ts new file mode 100644 index 0000000..a79af58 --- /dev/null +++ b/packages/common/types.ts @@ -0,0 +1,66 @@ +import type { Dayjs } from "dayjs" +import type { UUID } from "node:crypto" +import type { TransactionEntity } from "@actual-app/api/@types/loot-core/types/models" + +/** + * Defines how to interact with the bank + */ +export interface Bank { + /** + * Fetch all transactions in the specified days, from the given accounts + * @param interval Which days to fetch transactions for + * @param accountKeys The id of the accounts to fetch transactions from + * @returns An array of all transactions + */ + fetchTransactions: ( + interval: Interval, + ...accountKeys: ReadonlyArray + ) => Promise> + + /** + * Shutdown resources + */ + shutdown: () => Promise | void +} + +export interface Interval { + fromDate: Dayjs + toDate: Dayjs +} + +/** + * Describes how to interact with ActualBudget + */ +export interface Actual { + /** + * Import transactions following the rules defined in the ActualBudget instance + * If the transactions exists, it will be updated, or no change should be done. + * @param accountId The ActualBudget id to upload to + * @param transactions The transactions to import + * @returns An object describing what changed + */ + importTransactions: ( + accountId: UUID, + transactions: Iterable, + ) => Promise + + /** + * Disconnect from ActualBudget and release resources + */ + shutdown: () => Promise +} + +export interface ActualTransaction extends TransactionEntity { + account: UUID + payee_name?: string +} + +export interface ImportTransactionsResponse { + errors?: Message[] + added: number + updated: number +} + +export interface Message { + message: string +} diff --git a/src/bank/db/queries.ts b/packages/sparebank1/db/queries.ts similarity index 85% rename from src/bank/db/queries.ts rename to packages/sparebank1/db/queries.ts index 72faa99..dfd03ed 100644 --- a/src/bank/db/queries.ts +++ b/packages/sparebank1/db/queries.ts @@ -1,22 +1,16 @@ import Database from "better-sqlite3" - -import dayjs, { type Dayjs } from "dayjs" +import dayjs from "dayjs" import type { OAuthTokenResponse } from "@sb1/types.ts" - -export type TokenResponse = { - key: TokenKey - token: string - expires_at: Dayjs -} - -type TokenResponseRaw = { - [K in keyof TokenResponse]: K extends "expires_at" ? string : TokenResponse[K] -} - -export type TokenKey = "access-token" | "refresh-token" +import type { + TokenKey, + TokenResponse, + TokenResponseRaw, +} from "@sb1impl/db/types.ts" +import logger from "@common/logger.ts" export function createDb(filepath: string) { const db = new Database(filepath) + logger.info(`Started Sqlite database at '${filepath}'`) db.pragma("journal_mode = WAL") db.exec( "CREATE TABLE IF NOT EXISTS tokens ('key' VARCHAR PRIMARY KEY, token VARCHAR NOT NULL, expires_at DATETIME NOT NULL)", diff --git a/packages/sparebank1/db/types.ts b/packages/sparebank1/db/types.ts new file mode 100644 index 0000000..3883c7c --- /dev/null +++ b/packages/sparebank1/db/types.ts @@ -0,0 +1,13 @@ +import type { Dayjs } from "dayjs" + +export type TokenResponse = { + key: TokenKey + token: string + expires_at: Dayjs +} + +export type TokenResponseRaw = { + [K in keyof TokenResponse]: K extends "expires_at" ? string : TokenResponse[K] +} + +export type TokenKey = "access-token" | "refresh-token" diff --git a/src/mappings.ts b/packages/sparebank1/mappings.ts similarity index 96% rename from src/mappings.ts rename to packages/sparebank1/mappings.ts index cab08c6..d27f65b 100644 --- a/src/mappings.ts +++ b/packages/sparebank1/mappings.ts @@ -1,10 +1,10 @@ import type { UUID } from "node:crypto" import dayjs from "dayjs" -import { type ActualTransaction } from "@/actual.ts" import { ACTUAL_ACCOUNT_IDS, BANK_ACCOUNT_IDS } from "@/config.ts" import logger from "@common/logger.ts" import { toISODateString } from "@common/date.ts" import type { SB1Transaction } from "@sb1/types.ts" +import type { ActualTransaction } from "@common/types.ts" export function bankTransactionIntoActualTransaction( transaction: SB1Transaction, diff --git a/src/bank/sparebank1.ts b/packages/sparebank1/sparebank1.ts similarity index 80% rename from src/bank/sparebank1.ts rename to packages/sparebank1/sparebank1.ts index f7c01f6..e7b5253 100644 --- a/src/bank/sparebank1.ts +++ b/packages/sparebank1/sparebank1.ts @@ -2,39 +2,33 @@ import { BANK_INITIAL_REFRESH_TOKEN, BANK_OAUTH_CLIENT_ID, BANK_OAUTH_CLIENT_SECRET, + DB_DIRECTORY, + DB_FILENAME, } from "@/config.ts" import logger from "@common/logger.ts" -import dayjs, { type Dayjs } from "dayjs" +import dayjs from "dayjs" import type { Database } from "better-sqlite3" import { clearTokens, + createDb, fetchToken, insertTokens, - type TokenResponse, -} from "@/bank/db/queries.ts" +} from "@sb1impl/db/queries.ts" import * as Oauth from "@sb1/oauth.ts" import * as Transactions from "@sb1/transactions.ts" -import type { ActualTransaction } from "@/actual.ts" -import { bankTransactionIntoActualTransaction } from "@/mappings.ts" +import { bankTransactionIntoActualTransaction } from "./mappings.ts" import type { OAuthTokenResponse } from "@sb1/types.ts" - -export interface Bank { - fetchTransactions: ( - interval: Interval, - ...accountKeys: ReadonlyArray - ) => Promise> -} - -export interface Interval { - fromDate: Dayjs - toDate: Dayjs -} +import type { ActualTransaction, Bank, Interval } from "@common/types.ts" +import type { TokenResponse } from "@sb1impl/db/types.ts" +import { createDirIfMissing } from "@/fs.ts" export class Sparebank1Impl implements Bank { private readonly db: Database - constructor(db: Database) { - this.db = db + constructor() { + createDirIfMissing(DB_DIRECTORY) + const databaseFilePath = `${DB_DIRECTORY}/${DB_FILENAME}.sqlite` + this.db = createDb(databaseFilePath) } async fetchTransactions( @@ -50,6 +44,10 @@ export class Sparebank1Impl implements Bank { return sparebankTransactions.map(bankTransactionIntoActualTransaction) } + shutdown(): void { + this.db.close() + } + private async getAccessToken(): Promise { const accessToken = fetchToken(this.db, "access-token") diff --git a/packages/sparebank1Api/oauth.ts b/packages/sparebank1Api/oauth.ts index 5067324..8b85ec9 100644 --- a/packages/sparebank1Api/oauth.ts +++ b/packages/sparebank1Api/oauth.ts @@ -1,4 +1,4 @@ -import { OAuthTokenResponse, Result } from "./types" +import type { OAuthTokenResponse, Result } from "./types" import * as querystring from "node:querystring" import { baseUrl, failure, success } from "./common" import logger from "@common/logger" diff --git a/packages/sparebank1Api/transactions.ts b/packages/sparebank1Api/transactions.ts index 2825853..efb25b8 100644 --- a/packages/sparebank1Api/transactions.ts +++ b/packages/sparebank1Api/transactions.ts @@ -1,8 +1,9 @@ -import type { Interval, TransactionResponse } from "./types" +import type { TransactionResponse } from "./types" import * as querystring from "node:querystring" import { toISODateString } from "@common/date" import logger from "@common/logger" import { baseUrl } from "./common" +import type { Interval } from "@common/types.ts" export async function list( accessToken: string, @@ -18,7 +19,7 @@ export async function list( }) const url = `${baseUrl}/personal/banking/transactions?${queryString}` - logger.debug(`Sending GET request to '${url}'`) + logger.info(`GET '${url}'`) const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, diff --git a/packages/sparebank1Api/types.ts b/packages/sparebank1Api/types.ts index 4322c7d..af8ee8d 100644 --- a/packages/sparebank1Api/types.ts +++ b/packages/sparebank1Api/types.ts @@ -1,14 +1,7 @@ -import type { Dayjs } from "dayjs" - export type Success = { status: "success"; data: T } export type Failure = { status: "failure"; data: T } export type Result = Success | Failure -export interface Interval { - fromDate: Dayjs - toDate: Dayjs -} - export interface OAuthTokenResponse { access_token: string expires_in: number @@ -20,6 +13,10 @@ export interface OAuthTokenResponse { export type BookingStatus = "PENDING" | "BOOKED" +/** + * 18-character unique ID used to identify a transaction + * The value is "000000000000000000" until the transaction is booked, and might be set a few days later + */ export type NonUniqueId = "000000000000000000" | `${number}` export interface SB1Transaction { diff --git a/src/actual.ts b/src/actual.ts index b623db8..12affcd 100644 --- a/src/actual.ts +++ b/src/actual.ts @@ -5,38 +5,20 @@ import { ACTUAL_SERVER_URL, ACTUAL_SYNC_ID, } from "@/config.ts" -import type { TransactionEntity } from "@actual-app/api/@types/loot-core/types/models" -import { type UUID } from "node:crypto" import logger from "@common/logger.ts" - -export interface Actual { - importTransactions: ( - accountId: UUID, - transactions: Iterable, - ) => Promise - - shutdown: () => Promise -} - -export interface ActualTransaction extends TransactionEntity { - account: UUID - payee_name?: string -} - -export interface Message { - message: string -} - -export interface ImportTransactionsResponse { - errors?: Message[] - added: number - updated: number -} +import type { UUID } from "node:crypto" +import type { + Actual, + ActualTransaction, + ImportTransactionsResponse, +} from "@common/types.ts" +import { createDirIfMissing } from "@/fs.ts" export class ActualImpl implements Actual { private constructor() {} static async init(): Promise { + createDirIfMissing(ACTUAL_DATA_DIR) await actual.init({ // Budget data will be cached locally here, in subdirectories for each file. dataDir: ACTUAL_DATA_DIR, @@ -73,6 +55,7 @@ export class ActualImpl implements Actual { } async shutdown(): Promise { + logger.info(`Shutting down ActualBudget API for ${ACTUAL_SERVER_URL}`) return actual.shutdown() } diff --git a/src/fs.ts b/src/fs.ts index 790c71f..bdae7c2 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,10 +1,6 @@ import * as fs from "node:fs" import logger from "@common/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...`) diff --git a/src/main.ts b/src/main.ts index 16dfbb6..4b83c7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,32 +1,38 @@ -import { type Actual, ActualImpl } from "@/actual.ts" +import { ActualImpl } from "@/actual.ts" import { cronJobDaily } from "@/cron.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 "@common/logger.ts" import type { UUID } from "node:crypto" -import { createDb } from "@/bank/db/queries.ts" import { CronJob } from "cron" -import { createDirsIfMissing } from "@/fs.ts" import dayjs from "dayjs" +import type { Actual, Bank, Interval } from "@common/types.ts" +import { Sparebank1Impl } from "@sb1impl/sparebank1.ts" // 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 +async function main(): Promise { + logger.info("Starting application") + const bank = new Sparebank1Impl() + + if (process.env.ONCE) { + return await runOnce(bank) + } + await ActualImpl.testConnection() + await runCronJob(bank) +} + +// TODO log the days the transactions are fetched export async function moveTransactions( actual: Actual, bank: Bank, ): Promise { - // Fetch transactions from the bank const actualTransactions = await bank.fetchTransactions( relativeInterval(), ...BANK_ACCOUNT_IDS, @@ -61,53 +67,57 @@ function relativeInterval(): Interval { } } -async function main(): Promise { - logger.info("Starting application") +async function runOnce(bank: Bank) { + const actual = await ActualImpl.init() - createDirsIfMissing(ACTUAL_DATA_DIR, DB_DIRECTORY) + registerInterrupt(bank) - const databaseFilePath = `${DB_DIRECTORY}/${DB_FILENAME}.sqlite` - const db = createDb(databaseFilePath) - logger.info(`Started Sqlite database at '${databaseFilePath}'`) - const bank = new Sparebank1Impl(db) - - process.on("SIGINT", async () => { - logger.info("Caught interrupt signal") - await shutdown() - }) - - let cronJob: CronJob | undefined - if (process.env.ONCE) { - const actual = await ActualImpl.init() - try { - return await moveTransactions(actual, bank) - } finally { - await actual.shutdown() - await shutdown() - } - } else { - await ActualImpl.testConnection() + try { + return await moveTransactions(actual, bank) + } finally { + await actual.shutdown() + await shutdown(bank) } +} + +async function runCronJob(bank: Bank): Promise { + let actual: Actual | undefined + let cronJob: CronJob | undefined logger.info("Waiting for CronJob to start") - let actual: Actual | undefined try { + // TODO move try-catch inside closure? cronJob = cronJobDaily(async () => { actual = await ActualImpl.init() await moveTransactions(actual, bank) }) + registerInterrupt(bank, cronJob) } catch (exception) { logger.error(exception, "Caught exception at CronJob, shutting down!") - await shutdown() + await shutdown(bank, cronJob) } finally { + // TODO shuts down immediatly, move into closure await actual?.shutdown() } - - async function shutdown(): Promise { - logger.info("Shutting down, Bye!") - db.close() - cronJob?.stop() - } +} + +function registerInterrupt( + bank: Bank, + cronJob: CronJob | undefined = undefined, +): void { + process.on("SIGINT", async () => { + logger.info("Caught interrupt signal") + await shutdown(bank, cronJob) + }) +} + +async function shutdown( + bank: Bank, + cronJob: CronJob | undefined = undefined, +): Promise { + logger.info("Shutting down, Bye!") + await bank.shutdown() + cronJob?.stop() } void main() diff --git a/tsconfig.json b/tsconfig.json index ad3d7f7..ea00ad3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "paths": { "@/*": ["./src/*"], "@common/*": ["./packages/common/*"], - "@sb1/*": ["./packages/sparebank1Api/*"] + "@sb1/*": ["./packages/sparebank1Api/*"], + "@sb1impl/*": ["./packages/sparebank1/*"] } }, "exclude": ["node_modules", "./*.ts", "__test__"]