Compare commits
3 Commits
066331cca8
...
efa9e785f2
Author | SHA1 | Date | |
---|---|---|---|
efa9e785f2 | |||
71e70a2713 | |||
4f05382fc4 |
@ -50,7 +50,7 @@ function insertRefreshToken(
|
||||
db: Database.Database,
|
||||
refreshToken: string,
|
||||
expiresIn: number,
|
||||
) {
|
||||
): void {
|
||||
insert(db, "refresh-token", refreshToken, expiresIn)
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ function insert(
|
||||
key: TokenKey,
|
||||
token: string,
|
||||
expiresIn: number,
|
||||
) {
|
||||
): void {
|
||||
db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run(
|
||||
key,
|
||||
token,
|
||||
@ -82,3 +82,10 @@ export function fetchToken(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function clearTokens(db: Database.Database): void {
|
||||
db.prepare("DELETE FROM tokens WHERE key in ( ?, ? )").run([
|
||||
"access-token",
|
||||
"refresh-token",
|
||||
] as TokenKey[])
|
||||
}
|
||||
|
@ -1,17 +1,16 @@
|
||||
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 {
|
||||
clearTokens,
|
||||
fetchToken,
|
||||
insertTokens,
|
||||
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 +44,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 +62,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")
|
||||
|
||||
@ -80,34 +98,21 @@ export class Sparebank1Impl implements Bank {
|
||||
} else if (this.isValidToken(tokenResponse)) {
|
||||
return tokenResponse.token
|
||||
}
|
||||
// TODO clear database, if refresh token is invalid, will cause Exceptions on each call
|
||||
logger.warn("Refresh token expired, deleting tokens from database")
|
||||
clearTokens(this.db)
|
||||
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)
|
||||
|
||||
if (result.status === "failure") {
|
||||
throw logger.error({
|
||||
err: new Error(`Failed to fetch refresh token: '${result.data}'`),
|
||||
})
|
||||
throw new Error(`Failed to fetch refresh token: '${result.data}'`)
|
||||
}
|
||||
const oAuthToken = result.data
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
@ -4,6 +4,14 @@ import { LOG_LEVEL } from "../config.ts"
|
||||
/**
|
||||
* / Returns a logging instance with the default log-level "info"
|
||||
*/
|
||||
export default pino({
|
||||
level: LOG_LEVEL,
|
||||
})
|
||||
const logger = pino(
|
||||
pino.destination({
|
||||
level: LOG_LEVEL,
|
||||
}),
|
||||
)
|
||||
|
||||
console.log = function (...args): void {
|
||||
logger.info(args, args?.[0])
|
||||
}
|
||||
|
||||
export default logger
|
||||
|
57
src/main.ts
57
src/main.ts
@ -1,72 +1,67 @@
|
||||
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 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