🚀 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:
parent
066331cca8
commit
4f05382fc4
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
13
src/fs.ts
Normal 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 })
|
||||
}
|
||||
}
|
57
src/main.ts
57
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<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`
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user