Compare commits
No commits in common. "efa9e785f233ab628a40522ed2e624071755e9ed" and "066331cca841fb4a91cbadd6d624ee1b6119b3fe" have entirely different histories.
efa9e785f2
...
066331cca8
@ -50,7 +50,7 @@ function insertRefreshToken(
|
|||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
expiresIn: number,
|
expiresIn: number,
|
||||||
): void {
|
) {
|
||||||
insert(db, "refresh-token", refreshToken, expiresIn)
|
insert(db, "refresh-token", refreshToken, expiresIn)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +59,7 @@ function insert(
|
|||||||
key: TokenKey,
|
key: TokenKey,
|
||||||
token: string,
|
token: string,
|
||||||
expiresIn: number,
|
expiresIn: number,
|
||||||
): void {
|
) {
|
||||||
db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run(
|
db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run(
|
||||||
key,
|
key,
|
||||||
token,
|
token,
|
||||||
@ -82,10 +82,3 @@ 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,16 +1,17 @@
|
|||||||
import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts"
|
import {
|
||||||
|
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, { Dayjs } from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { Database } from "better-sqlite3"
|
import { Database } from "better-sqlite3"
|
||||||
import {
|
import {
|
||||||
clearTokens,
|
|
||||||
fetchToken,
|
fetchToken,
|
||||||
insertTokens,
|
insertTokens,
|
||||||
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
|
||||||
@ -44,15 +45,9 @@ export interface TransactionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Bank {
|
export interface Bank {
|
||||||
fetchTransactions: (
|
transactionsPastDay: (
|
||||||
interval: Interval,
|
|
||||||
...accountKeys: ReadonlyArray<string>
|
...accountKeys: ReadonlyArray<string>
|
||||||
) => Promise<ReadonlyArray<ActualTransaction>>
|
) => Promise<TransactionResponse>
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interval {
|
|
||||||
fromDate: Dayjs
|
|
||||||
toDate: Dayjs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Sparebank1Impl implements Bank {
|
export class Sparebank1Impl implements Bank {
|
||||||
@ -62,19 +57,6 @@ 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")
|
||||||
|
|
||||||
@ -98,21 +80,34 @@ export class Sparebank1Impl implements Bank {
|
|||||||
} else if (this.isValidToken(tokenResponse)) {
|
} else if (this.isValidToken(tokenResponse)) {
|
||||||
return tokenResponse.token
|
return tokenResponse.token
|
||||||
}
|
}
|
||||||
logger.warn("Refresh token expired, deleting tokens from database")
|
// TODO clear database, if refresh token is invalid, will cause Exceptions on each call
|
||||||
clearTokens(this.db)
|
|
||||||
throw new Error("Refresh token is expired. Create a new one")
|
throw new Error("Refresh token is expired. Create a new one")
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchNewTokens(): Promise<OAuthTokenResponse> {
|
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)
|
||||||
|
|
||||||
if (result.status === "failure") {
|
if (result.status === "failure") {
|
||||||
throw new Error(`Failed to fetch refresh token: '${result.data}'`)
|
throw logger.error({
|
||||||
|
err: new Error(`Failed to fetch refresh token: '${result.data}'`),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const oAuthToken = result.data
|
const oAuthToken = result.data
|
||||||
|
|
||||||
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,13 +20,16 @@ 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>,
|
||||||
interval?: Interval,
|
timePeriod?: {
|
||||||
|
fromDate: Dayjs
|
||||||
|
toDate: Dayjs
|
||||||
|
},
|
||||||
): Promise<TransactionResponse> {
|
): Promise<TransactionResponse> {
|
||||||
const queryString = querystring.stringify({
|
const queryString = querystring.stringify({
|
||||||
accountKey: accountKeys,
|
accountKey: accountKeys,
|
||||||
...(interval && {
|
...(timePeriod && {
|
||||||
fromDate: toISODateString(interval.fromDate),
|
fromDate: toISODateString(timePeriod.fromDate),
|
||||||
toDate: toISODateString(interval.toDate),
|
toDate: toISODateString(timePeriod.toDate),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
13
src/fs.ts
13
src/fs.ts
@ -1,13 +0,0 @@
|
|||||||
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,14 +4,6 @@ import { LOG_LEVEL } from "../config.ts"
|
|||||||
/**
|
/**
|
||||||
* / Returns a logging instance with the default log-level "info"
|
* / Returns a logging instance with the default log-level "info"
|
||||||
*/
|
*/
|
||||||
const logger = pino(
|
export default pino({
|
||||||
pino.destination({
|
level: LOG_LEVEL,
|
||||||
level: LOG_LEVEL,
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log = function (...args): void {
|
|
||||||
logger.info(args, args?.[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default logger
|
|
||||||
|
59
src/main.ts
59
src/main.ts
@ -1,67 +1,72 @@
|
|||||||
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 { type Bank, type Interval, Sparebank1Impl } from "@/bank/sparebank1.ts"
|
import {
|
||||||
|
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 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 actualTransactions = await bank.fetchTransactions(
|
const transactions = await fetchTransactionsFromPastDay(bank)
|
||||||
relativeInterval(),
|
logger.info(`Fetched ${transactions.length} transactions`)
|
||||||
...BANK_ACCOUNT_IDS,
|
|
||||||
)
|
|
||||||
logger.info(`Fetched ${actualTransactions.length} transactions`)
|
|
||||||
|
|
||||||
const transactionsByAccount = Object.groupBy(
|
const actualTransactions = transactions.map((transaction) =>
|
||||||
|
// TODO move to Bank interface?
|
||||||
|
bankTransactionIntoActualTransaction(transaction),
|
||||||
|
)
|
||||||
|
|
||||||
|
const transactionsGroup = Object.groupBy(
|
||||||
actualTransactions,
|
actualTransactions,
|
||||||
(transaction) => transaction.account,
|
(transaction) => transaction.account,
|
||||||
)
|
)
|
||||||
|
|
||||||
const responses = await Promise.all(
|
const response = await Promise.all(
|
||||||
Object.entries(transactionsByAccount).map(([accountId, transactions]) =>
|
Object.entries(transactionsGroup).map(([accountId, transactions]) =>
|
||||||
actual.importTransactions(accountId as UUID, transactions || []),
|
actual.importTransactions(accountId as UUID, transactions || []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(response, "Finished importing transactions")
|
||||||
responses.map((response) => ({
|
|
||||||
added: response.added,
|
|
||||||
updated: response.updated,
|
|
||||||
})),
|
|
||||||
"Finished importing transactions",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeInterval(): Interval {
|
async function fetchTransactionsFromPastDay(
|
||||||
const today = dayjs()
|
bank: Bank,
|
||||||
return {
|
): Promise<ReadonlyArray<Transaction>> {
|
||||||
fromDate: today.subtract(TRANSACTION_RELATIVE_FROM_DATE, "days"),
|
const response = await bank.transactionsPastDay(...BANK_ACCOUNT_IDS)
|
||||||
toDate: today.subtract(TRANSACTION_RELATIVE_TO_DATE, "days"),
|
return response.transactions
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
createDirsIfMissing(ACTUAL_DATA_DIR, DB_DIRECTORY)
|
createDirIfMissing(ACTUAL_DATA_DIR)
|
||||||
|
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,45 +1,41 @@
|
|||||||
import type {
|
import type {
|
||||||
Bank,
|
Bank,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
Interval,
|
TransactionResponse,
|
||||||
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 fetchTransactions(
|
async transactionsPastDay(
|
||||||
_interval: Interval,
|
|
||||||
_accountIds: ReadonlyArray<string> | string,
|
_accountIds: ReadonlyArray<string> | string,
|
||||||
): Promise<ReadonlyArray<ActualTransaction>> {
|
): Promise<TransactionResponse> {
|
||||||
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",
|
|
||||||
}
|
}
|
||||||
const bankTransactions: ReadonlyArray<Transaction> = [
|
return {
|
||||||
{
|
transactions: [
|
||||||
id: "1",
|
{
|
||||||
nonUniqueId: "1",
|
id: "1",
|
||||||
amount: 100,
|
nonUniqueId: "1",
|
||||||
...someFields,
|
amount: 100,
|
||||||
},
|
...someFields,
|
||||||
{
|
},
|
||||||
id: "2",
|
{
|
||||||
nonUniqueId: "2",
|
id: "2",
|
||||||
amount: 200,
|
nonUniqueId: "2",
|
||||||
...someFields,
|
amount: 200,
|
||||||
},
|
...someFields,
|
||||||
{
|
},
|
||||||
id: "3",
|
{
|
||||||
nonUniqueId: "3",
|
id: "3",
|
||||||
amount: -50,
|
nonUniqueId: "3",
|
||||||
...someFields,
|
amount: -50,
|
||||||
},
|
...someFields,
|
||||||
]
|
},
|
||||||
return bankTransactions.map(bankTransactionIntoActualTransaction)
|
],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user