diff --git a/src/actual.ts b/src/actual.ts index 29b621d..038d474 100644 --- a/src/actual.ts +++ b/src/actual.ts @@ -12,12 +12,16 @@ import logger from "@/logger.ts" export interface Actual { importTransactions: ( accountId: UUID, - transactions: ReadonlyArray, + transactions: ReadonlyArray, ) => Promise shutdown: () => Promise } +export interface ActualTransaction extends TransactionEntity { + payee_name?: string +} + export interface Message { message: string } @@ -41,19 +45,23 @@ export class ActualImpl implements Actual { password: ACTUAL_PASSWORD, }) logger.info(`Initialized ActualBudget API for ${ACTUAL_SERVER_URL}`) - await actual.downloadBudget(ACTUAL_SYNC_ID) - logger.info(`Downloaded budget`) + await this.downloadBudget() return new ActualImpl() } async importTransactions( accountId: UUID, - transactions: ReadonlyArray, + transactions: ReadonlyArray, ): Promise { - return await actual.importTransactions(accountId, transactions) + return actual.importTransactions(accountId, transactions) } - async shutdown() { - return await actual.shutdown() + async shutdown(): Promise { + return actual.shutdown() + } + + private static async downloadBudget(): Promise { + await actual.downloadBudget(ACTUAL_SYNC_ID) + logger.info(`Downloaded budget`) } } diff --git a/src/bank/db/queries.ts b/src/bank/db/queries.ts index 2641e91..230f74f 100644 --- a/src/bank/db/queries.ts +++ b/src/bank/db/queries.ts @@ -9,6 +9,12 @@ export type TokenResponse = { expires_at: Dayjs } +export type TokenResponseRaw = { + key: TokenResponse["key"] + token: TokenResponse["token"] + expires_at: string +} + export type TokenKey = "access-token" | "refresh-token" export function createDb(filename: string) { @@ -57,7 +63,7 @@ function insert( db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run( key, token, - dayjs().add(expiresIn, "seconds"), + dayjs().add(expiresIn, "seconds").toISOString(), ) } @@ -65,9 +71,14 @@ export function fetchToken( db: Database.Database, tokenKey: TokenKey, ): TokenResponse | null { + const response = db + .prepare("SELECT * FROM tokens WHERE key = ?") + .get(tokenKey) as TokenResponseRaw | null + return ( - (db - .prepare("SELECT * FROM tokens WHERE 'key' = ?") - .get(tokenKey) as TokenResponse) ?? null + response && { + ...response, + expires_at: dayjs(response.expires_at), + } ) } diff --git a/src/bank/sparebank1.ts b/src/bank/sparebank1.ts index 94eb891..9065389 100644 --- a/src/bank/sparebank1.ts +++ b/src/bank/sparebank1.ts @@ -3,8 +3,13 @@ import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts" import logger from "@/logger.ts" import dayjs from "dayjs" import { Database } from "better-sqlite3" -import { fetchToken, insertTokens, TokenResponse } from "@/bank/db/queries.ts" +import { + fetchToken, + insertTokens, + type TokenResponse, +} from "@/bank/db/queries.ts" import * as Api from "./sparebank1Api.ts" +import { toISODateString } from "@/date.ts" export interface OAuthTokenResponse { access_token: string @@ -17,21 +22,25 @@ export interface OAuthTokenResponse { export interface Transaction { id: string - date: string - amount: number - description: string + nonUniqueId: string + date: number // Unix time + amount: number // Amount in NOK cleanedDescription: string remoteAccountName: string [key: string]: string | number | boolean | unknown } +export interface TransactionResponse { + transactions: ReadonlyArray +} + export type Bank = Sparebank1 export interface Sparebank1 { transactionsPastDay: ( accountKeys: ReadonlyArray | string, - ) => Promise> + ) => Promise } export class Sparebank1Impl implements Sparebank1 { @@ -53,11 +62,13 @@ export class Sparebank1Impl implements Sparebank1 { } private isValidToken(tokenResponse: TokenResponse): boolean { - return dayjs() < tokenResponse.expires_at + // TODO make sure the same timezone is used. Db uses UTC + return dayjs().isBefore(tokenResponse.expires_at) } private async getRefreshToken(): Promise { const tokenResponse = fetchToken(this.db, "refresh-token") + logger.debug(`Database returned refresh token: '%o'`, tokenResponse) if (!tokenResponse) { return BANK_INITIAL_REFRESH_TOKEN } else if (this.isValidToken(tokenResponse)) { @@ -69,10 +80,13 @@ export class Sparebank1Impl implements Sparebank1 { async fetchNewTokens(): Promise { const refreshToken = await this.getRefreshToken() + logger.debug(`Found refresh token '${refreshToken}'`) const result = await Api.refreshToken(refreshToken) if (result.status === "failure") { - throw new Error("Failed to fetch refresh token") + throw logger.error({ + err: new Error(`Failed to fetch refresh token: '${result.data}'`), + }) } const oAuthToken = result.data @@ -82,33 +96,34 @@ export class Sparebank1Impl implements Sparebank1 { async transactionsPastDay( accountKeys: ReadonlyArray | string, - ): Promise> { + ): Promise { const today = dayjs() const lastDay = today.subtract(1, "day") + const queries = new URLSearchParams({ // TODO allow multiple accountKeys accountKey: typeof accountKeys === "string" ? accountKeys : accountKeys[0], - fromDate: lastDay.toString(), - toDate: today.toString(), + fromDate: toISODateString(lastDay), + toDate: toISODateString(today), }) const accessToken = await this.getAccessToken() - const response = await fetch( - `${Sparebank1Impl.baseUrl}/transactions?${queries}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, + logger.debug(`Found access token '${accessToken}'`) + const url = `${Sparebank1Impl.baseUrl}/personal/banking/transactions?${queries}` + logger.debug(`Sending GET request to '${url}'`) + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.sparebank1.v1+json;charset=utf-8", }, - ) + }) + logger.debug(`Received response with status '${response.status}'`) if (response.ok) { return response.json() } else { - logger.warn( - `transactionsPastDay returned a ${response.status} with the text ${response.statusText}`, - ) - return [] + logger.warn(await response.json()) + return { transactions: [] } } } } diff --git a/src/bank/sparebank1Api.ts b/src/bank/sparebank1Api.ts index 6e6855c..13d0bc3 100644 --- a/src/bank/sparebank1Api.ts +++ b/src/bank/sparebank1Api.ts @@ -1,5 +1,6 @@ import { BANK_OAUTH_CLIENT_ID, BANK_OAUTH_CLIENT_SECRET } from "../../config.ts" import { OAuthTokenResponse } from "@/bank/sparebank1.ts" +import logger from "@/logger.ts" const baseUrl = "https://api.sparebank1.no" @@ -24,9 +25,17 @@ export async function refreshToken( refresh_token: refreshToken, grant_type: "refresh_token", }) - const response = await fetch(`${baseUrl}/token?${queries}`) + const url = `${baseUrl}/oauth/token?${queries}` + logger.debug("Sending POST request to url: '%s'", url) + const response = await fetch(url, { + method: "post", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + logger.debug(`Received response with status '${response.status}'`) if (!response.ok) { - return failure(response.statusText) + return failure(await response.text()) } return success(await response.json()) } diff --git a/src/date.ts b/src/date.ts new file mode 100644 index 0000000..0d33fc1 --- /dev/null +++ b/src/date.ts @@ -0,0 +1,5 @@ +import { type Dayjs } from "dayjs" + +export function toISODateString(day: Dayjs): string { + return `${day.year()}-${(day.month() + 1).toString().padStart(2, "0")}-${day.date().toString().padStart(2, "0")}` +} diff --git a/src/main.ts b/src/main.ts index 8baf10e..b41d595 100644 --- a/src/main.ts +++ b/src/main.ts @@ -48,7 +48,8 @@ export async function daily(actual: Actual, bank: Bank): Promise { async function fetchTransactionsFromPastDay( bank: Bank, ): Promise> { - return bank.transactionsPastDay(BANK_ACCOUNT_IDS) + const response = await bank.transactionsPastDay(BANK_ACCOUNT_IDS) + return response.transactions } function createCacheDirIfMissing(): void { @@ -58,27 +59,29 @@ function createCacheDirIfMissing(): void { } } +// TODO add a script to run an immediate job, without cron +// TODO catch ^C to stop server async function main(): Promise { logger.info("Starting application") createCacheDirIfMissing() const actual = await ActualImpl.init() - const databaseFileName = "default.sqlite" + const databaseFileName = "default.sqlite" // TODO move name to env const db = createDb(databaseFileName) logger.info(`Started SQLlite database with filename="${databaseFileName}"`) logger.info("Waiting for CRON job to start") - cronJobDaily(async () => { - logger.info("Running daily job") - await daily(actual, new Sparebank1Impl(db)) - logger.info("Finished daily job") - }) + // cronJobDaily(async () => { + logger.info("Running daily job") + await daily(actual, new Sparebank1Impl(db)) + logger.info("Finished daily job") + // }) - // logger.info("Shutting down") - // await actual.shutdown() - // db.close() + logger.info("Shutting down") + await actual.shutdown() + db.close() } void main() diff --git a/src/mappings.ts b/src/mappings.ts index d513af3..a3126dd 100644 --- a/src/mappings.ts +++ b/src/mappings.ts @@ -1,21 +1,22 @@ import type { Transaction } from "@/bank/sparebank1.ts" -import type { TransactionEntity } from "@actual-app/api/@types/loot-core/types/models" import type { UUID } from "node:crypto" +import dayjs from "dayjs" +import { toISODateString } from "@/date.ts" +import { type ActualTransaction } from "@/actual.ts" -// TODO more fields / correct fields? export function bankTransactionIntoActualTransaction( transaction: Transaction, accountId: UUID, -): TransactionEntity { +): ActualTransaction { return { id: transaction.id, // Transactions with the same id will be ignored - imported_id: transaction.id, + imported_id: transaction.nonUniqueId, account: accountId, // The value without decimals amount: transaction.amount * 100, - date: transaction.date, - payee: transaction.description, + date: toISODateString(dayjs(transaction.date)), + payee_name: transaction.cleanedDescription, } } diff --git a/tests/stubs/bankStub.ts b/tests/stubs/bankStub.ts index f2b60dc..85539d3 100644 --- a/tests/stubs/bankStub.ts +++ b/tests/stubs/bankStub.ts @@ -1,31 +1,36 @@ -import type { Bank, Transaction } from "@/bank/sparebank1.ts" +import type { Bank, TransactionResponse } from "@/bank/sparebank1.ts" +import dayjs from "dayjs" export class BankStub implements Bank { async transactionsPastDay( _accountIds: ReadonlyArray | string, - ): Promise> { + ): Promise { const someFields = { - date: "2019-08-20", - description: "Test transaction", + date: dayjs("2019-08-20").unix(), cleanedDescription: "Test transaction", remoteAccountName: "Test account", } - return [ - { - id: "1", - amount: 100, - ...someFields, - }, - { - id: "2", - amount: 200, - ...someFields, - }, - { - id: "3", - amount: -50, - ...someFields, - }, - ] + return { + transactions: [ + { + id: "1", + nonUniqueId: "1", + amount: 100, + ...someFields, + }, + { + id: "2", + nonUniqueId: "2", + amount: 200, + ...someFields, + }, + { + id: "3", + nonUniqueId: "3", + amount: -50, + ...someFields, + }, + ], + } } }