🚀 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 {
|
import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts"
|
||||||
BANK_INITIAL_REFRESH_TOKEN,
|
|
||||||
TRANSACTION_RELATIVE_FROM_DATE,
|
|
||||||
TRANSACTION_RELATIVE_TO_DATE,
|
|
||||||
} from "@/../config.ts"
|
|
||||||
import logger from "@/logger.ts"
|
import logger from "@/logger.ts"
|
||||||
import dayjs from "dayjs"
|
import dayjs, { Dayjs } from "dayjs"
|
||||||
import { Database } from "better-sqlite3"
|
import { Database } from "better-sqlite3"
|
||||||
import {
|
import {
|
||||||
fetchToken,
|
fetchToken,
|
||||||
@ -12,6 +8,8 @@ import {
|
|||||||
type TokenResponse,
|
type TokenResponse,
|
||||||
} from "@/bank/db/queries.ts"
|
} from "@/bank/db/queries.ts"
|
||||||
import * as Api from "./sparebank1Api.ts"
|
import * as Api from "./sparebank1Api.ts"
|
||||||
|
import { ActualTransaction } from "@/actual.ts"
|
||||||
|
import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
|
||||||
|
|
||||||
export interface OAuthTokenResponse {
|
export interface OAuthTokenResponse {
|
||||||
access_token: string
|
access_token: string
|
||||||
@ -45,9 +43,15 @@ export interface TransactionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Bank {
|
export interface Bank {
|
||||||
transactionsPastDay: (
|
fetchTransactions: (
|
||||||
|
interval: Interval,
|
||||||
...accountKeys: ReadonlyArray<string>
|
...accountKeys: ReadonlyArray<string>
|
||||||
) => Promise<TransactionResponse>
|
) => Promise<ReadonlyArray<ActualTransaction>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interval {
|
||||||
|
fromDate: Dayjs
|
||||||
|
toDate: Dayjs
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Sparebank1Impl implements Bank {
|
export class Sparebank1Impl implements Bank {
|
||||||
@ -57,6 +61,19 @@ export class Sparebank1Impl implements Bank {
|
|||||||
this.db = db
|
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> {
|
private async getAccessToken(): Promise<string> {
|
||||||
const accessToken = fetchToken(this.db, "access-token")
|
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")
|
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 refreshToken = await this.getRefreshToken()
|
||||||
const result = await Api.refreshToken(refreshToken)
|
const result = await Api.refreshToken(refreshToken)
|
||||||
|
|
||||||
@ -98,16 +115,4 @@ export class Sparebank1Impl implements Bank {
|
|||||||
insertTokens(this.db, oAuthToken)
|
insertTokens(this.db, oAuthToken)
|
||||||
return 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 { BANK_OAUTH_CLIENT_ID, BANK_OAUTH_CLIENT_SECRET } from "@/../config.ts"
|
||||||
import type {
|
import type {
|
||||||
|
Interval,
|
||||||
OAuthTokenResponse,
|
OAuthTokenResponse,
|
||||||
TransactionResponse,
|
TransactionResponse,
|
||||||
} from "@/bank/sparebank1.ts"
|
} from "@/bank/sparebank1.ts"
|
||||||
import logger from "@/logger.ts"
|
import logger from "@/logger.ts"
|
||||||
import { type Dayjs } from "dayjs"
|
|
||||||
import { toISODateString } from "@/date.ts"
|
import { toISODateString } from "@/date.ts"
|
||||||
import * as querystring from "node:querystring"
|
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(
|
export async function transactions(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
accountKeys: string | ReadonlyArray<string>,
|
accountKeys: string | ReadonlyArray<string>,
|
||||||
timePeriod?: {
|
interval?: Interval,
|
||||||
fromDate: Dayjs
|
|
||||||
toDate: Dayjs
|
|
||||||
},
|
|
||||||
): Promise<TransactionResponse> {
|
): Promise<TransactionResponse> {
|
||||||
const queryString = querystring.stringify({
|
const queryString = querystring.stringify({
|
||||||
accountKey: accountKeys,
|
accountKey: accountKeys,
|
||||||
...(timePeriod && {
|
...(interval && {
|
||||||
fromDate: toISODateString(timePeriod.fromDate),
|
fromDate: toISODateString(interval.fromDate),
|
||||||
toDate: toISODateString(timePeriod.toDate),
|
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 { type Actual, ActualImpl } from "@/actual.ts"
|
||||||
import { cronJobDaily } from "@/cron.ts"
|
import { cronJobDaily } from "@/cron.ts"
|
||||||
import {
|
import { type Bank, type Interval, Sparebank1Impl } from "@/bank/sparebank1.ts"
|
||||||
type Bank,
|
|
||||||
Sparebank1Impl,
|
|
||||||
type Transaction,
|
|
||||||
} from "@/bank/sparebank1.ts"
|
|
||||||
import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
|
|
||||||
import {
|
import {
|
||||||
ACTUAL_DATA_DIR,
|
ACTUAL_DATA_DIR,
|
||||||
BANK_ACCOUNT_IDS,
|
BANK_ACCOUNT_IDS,
|
||||||
DB_DIRECTORY,
|
DB_DIRECTORY,
|
||||||
DB_FILENAME,
|
DB_FILENAME,
|
||||||
|
TRANSACTION_RELATIVE_FROM_DATE,
|
||||||
|
TRANSACTION_RELATIVE_TO_DATE,
|
||||||
} from "../config.ts"
|
} from "../config.ts"
|
||||||
import logger from "@/logger.ts"
|
import logger from "@/logger.ts"
|
||||||
import type { UUID } from "node:crypto"
|
import type { UUID } from "node:crypto"
|
||||||
import { createDb } from "@/bank/db/queries.ts"
|
import { createDb } from "@/bank/db/queries.ts"
|
||||||
import * as fs from "node:fs"
|
|
||||||
import { CronJob } from "cron"
|
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 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 move tsx to devDependency. Requires ts support for Node with support for @ alias
|
||||||
// TODO verbatimSyntax in tsconfig, conflicts with jest
|
// TODO verbatimSyntax in tsconfig, conflicts with jest
|
||||||
// TODO multi module project. Main | DAL | Sparebank1 impl
|
// 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 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> {
|
export async function daily(actual: Actual, bank: Bank): Promise<void> {
|
||||||
// Fetch transactions from the bank
|
// Fetch transactions from the bank
|
||||||
const transactions = await fetchTransactionsFromPastDay(bank)
|
const actualTransactions = await bank.fetchTransactions(
|
||||||
logger.info(`Fetched ${transactions.length} transactions`)
|
relativeInterval(),
|
||||||
|
...BANK_ACCOUNT_IDS,
|
||||||
const actualTransactions = transactions.map((transaction) =>
|
|
||||||
// TODO move to Bank interface?
|
|
||||||
bankTransactionIntoActualTransaction(transaction),
|
|
||||||
)
|
)
|
||||||
|
logger.info(`Fetched ${actualTransactions.length} transactions`)
|
||||||
|
|
||||||
const transactionsGroup = Object.groupBy(
|
const transactionsByAccount = Object.groupBy(
|
||||||
actualTransactions,
|
actualTransactions,
|
||||||
(transaction) => transaction.account,
|
(transaction) => transaction.account,
|
||||||
)
|
)
|
||||||
|
|
||||||
const response = await Promise.all(
|
const responses = await Promise.all(
|
||||||
Object.entries(transactionsGroup).map(([accountId, transactions]) =>
|
Object.entries(transactionsByAccount).map(([accountId, transactions]) =>
|
||||||
actual.importTransactions(accountId as UUID, 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(
|
function relativeInterval(): Interval {
|
||||||
bank: Bank,
|
const today = dayjs()
|
||||||
): Promise<ReadonlyArray<Transaction>> {
|
return {
|
||||||
const response = await bank.transactionsPastDay(...BANK_ACCOUNT_IDS)
|
fromDate: today.subtract(TRANSACTION_RELATIVE_FROM_DATE, "days"),
|
||||||
return response.transactions
|
toDate: today.subtract(TRANSACTION_RELATIVE_TO_DATE, "days"),
|
||||||
}
|
|
||||||
|
|
||||||
function createDirIfMissing(directory: string): void {
|
|
||||||
if (!fs.existsSync(directory)) {
|
|
||||||
logger.info(`Missing '${directory}', creating...`)
|
|
||||||
fs.mkdirSync(directory, { recursive: true })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
logger.info("Starting application")
|
logger.info("Starting application")
|
||||||
|
|
||||||
createDirIfMissing(ACTUAL_DATA_DIR)
|
createDirsIfMissing(ACTUAL_DATA_DIR, DB_DIRECTORY)
|
||||||
createDirIfMissing(DB_DIRECTORY)
|
|
||||||
|
|
||||||
const actual = await ActualImpl.init()
|
const actual = await ActualImpl.init()
|
||||||
const databaseFilePath = `${DB_DIRECTORY}/${DB_FILENAME}.sqlite`
|
const databaseFilePath = `${DB_DIRECTORY}/${DB_FILENAME}.sqlite`
|
||||||
|
@ -1,41 +1,45 @@
|
|||||||
import type {
|
import type {
|
||||||
Bank,
|
Bank,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
TransactionResponse,
|
Interval,
|
||||||
|
Transaction,
|
||||||
} from "@/bank/sparebank1.ts"
|
} from "@/bank/sparebank1.ts"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
|
import { ActualTransaction } from "@/actual.ts"
|
||||||
|
import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
|
||||||
|
|
||||||
export class BankStub implements Bank {
|
export class BankStub implements Bank {
|
||||||
async transactionsPastDay(
|
async fetchTransactions(
|
||||||
|
_interval: Interval,
|
||||||
_accountIds: ReadonlyArray<string> | string,
|
_accountIds: ReadonlyArray<string> | string,
|
||||||
): Promise<TransactionResponse> {
|
): Promise<ReadonlyArray<ActualTransaction>> {
|
||||||
const someFields = {
|
const someFields = {
|
||||||
date: dayjs("2019-08-20").unix(),
|
date: dayjs("2019-08-20").unix(),
|
||||||
cleanedDescription: "Test transaction",
|
cleanedDescription: "Test transaction",
|
||||||
remoteAccountName: "Test account",
|
remoteAccountName: "Test account",
|
||||||
bookingStatus: "BOOKED" as BookingStatus,
|
bookingStatus: "BOOKED" as BookingStatus,
|
||||||
|
accountKey: "1",
|
||||||
}
|
}
|
||||||
return {
|
const bankTransactions: ReadonlyArray<Transaction> = [
|
||||||
transactions: [
|
{
|
||||||
{
|
id: "1",
|
||||||
id: "1",
|
nonUniqueId: "1",
|
||||||
nonUniqueId: "1",
|
amount: 100,
|
||||||
amount: 100,
|
...someFields,
|
||||||
...someFields,
|
},
|
||||||
},
|
{
|
||||||
{
|
id: "2",
|
||||||
id: "2",
|
nonUniqueId: "2",
|
||||||
nonUniqueId: "2",
|
amount: 200,
|
||||||
amount: 200,
|
...someFields,
|
||||||
...someFields,
|
},
|
||||||
},
|
{
|
||||||
{
|
id: "3",
|
||||||
id: "3",
|
nonUniqueId: "3",
|
||||||
nonUniqueId: "3",
|
amount: -50,
|
||||||
amount: -50,
|
...someFields,
|
||||||
...someFields,
|
},
|
||||||
},
|
]
|
||||||
],
|
return bankTransactions.map(bankTransactionIntoActualTransaction)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user