🌟 Refactor
- Moved mappings into Sb1 impl - Moved Actual types to @common - Moved createDirIfMissing to respective functions - Refactored main into multiple functions - Moved create db into Sb1impl and close - Log requests on info
This commit is contained in:
parent
06cc89f762
commit
4367c24fb0
@ -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:
|
||||
|
66
packages/common/types.ts
Normal file
66
packages/common/types.ts
Normal file
@ -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<string>
|
||||
) => Promise<ReadonlyArray<ActualTransaction>>
|
||||
|
||||
/**
|
||||
* Shutdown resources
|
||||
*/
|
||||
shutdown: () => Promise<void> | 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<ActualTransaction>,
|
||||
) => Promise<ImportTransactionsResponse>
|
||||
|
||||
/**
|
||||
* Disconnect from ActualBudget and release resources
|
||||
*/
|
||||
shutdown: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface ActualTransaction extends TransactionEntity {
|
||||
account: UUID
|
||||
payee_name?: string
|
||||
}
|
||||
|
||||
export interface ImportTransactionsResponse {
|
||||
errors?: Message[]
|
||||
added: number
|
||||
updated: number
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
message: string
|
||||
}
|
@ -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)",
|
13
packages/sparebank1/db/types.ts
Normal file
13
packages/sparebank1/db/types.ts
Normal file
@ -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"
|
@ -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,
|
@ -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<string>
|
||||
) => Promise<ReadonlyArray<ActualTransaction>>
|
||||
}
|
||||
|
||||
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<string> {
|
||||
const accessToken = fetchToken(this.db, "access-token")
|
||||
|
@ -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"
|
||||
|
@ -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}`,
|
||||
|
@ -1,14 +1,7 @@
|
||||
import type { Dayjs } from "dayjs"
|
||||
|
||||
export type Success<T> = { status: "success"; data: T }
|
||||
export type Failure<T> = { status: "failure"; data: T }
|
||||
export type Result<OK, Err> = Success<OK> | Failure<Err>
|
||||
|
||||
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 {
|
||||
|
@ -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<ActualTransaction>,
|
||||
) => Promise<ImportTransactionsResponse>
|
||||
|
||||
shutdown: () => Promise<void>
|
||||
}
|
||||
|
||||
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<Actual> {
|
||||
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<void> {
|
||||
logger.info(`Shutting down ActualBudget API for ${ACTUAL_SERVER_URL}`)
|
||||
return actual.shutdown()
|
||||
}
|
||||
|
||||
|
@ -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...`)
|
||||
|
92
src/main.ts
92
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
logger.info("Shutting down, Bye!")
|
||||
await bank.shutdown()
|
||||
cronJob?.stop()
|
||||
}
|
||||
|
||||
void main()
|
||||
|
@ -13,7 +13,8 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@common/*": ["./packages/common/*"],
|
||||
"@sb1/*": ["./packages/sparebank1Api/*"]
|
||||
"@sb1/*": ["./packages/sparebank1Api/*"],
|
||||
"@sb1impl/*": ["./packages/sparebank1/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "./*.ts", "__test__"]
|
||||
|
Loading…
x
Reference in New Issue
Block a user