🎉 Working prototype

- Added missing field payee_name to TransactionEntity type
- Date function to convert DayJs to DateString
- Temp disabled cronjob for testing
- Fixed mapping to ActualModel
- fetchToken db function returns DayJs object for date
- Fixed fetching tokens from db
- Logging
- Fixed fetch previous day transactions
- Fixed token refresh
- Fixed stub based on model changes
This commit is contained in:
Martin Berg Alstad 2025-01-25 18:01:47 +01:00
parent 9a00592a7a
commit b61903f5c8
Signed by: martials
GPG Key ID: A3824877B269F2E2
8 changed files with 128 additions and 71 deletions

View File

@ -12,12 +12,16 @@ import logger from "@/logger.ts"
export interface Actual { export interface Actual {
importTransactions: ( importTransactions: (
accountId: UUID, accountId: UUID,
transactions: ReadonlyArray<TransactionEntity>, transactions: ReadonlyArray<ActualTransaction>,
) => Promise<ImportTransactionsResponse> ) => Promise<ImportTransactionsResponse>
shutdown: () => Promise<void> shutdown: () => Promise<void>
} }
export interface ActualTransaction extends TransactionEntity {
payee_name?: string
}
export interface Message { export interface Message {
message: string message: string
} }
@ -41,19 +45,23 @@ export class ActualImpl implements Actual {
password: ACTUAL_PASSWORD, password: ACTUAL_PASSWORD,
}) })
logger.info(`Initialized ActualBudget API for ${ACTUAL_SERVER_URL}`) logger.info(`Initialized ActualBudget API for ${ACTUAL_SERVER_URL}`)
await actual.downloadBudget(ACTUAL_SYNC_ID) await this.downloadBudget()
logger.info(`Downloaded budget`)
return new ActualImpl() return new ActualImpl()
} }
async importTransactions( async importTransactions(
accountId: UUID, accountId: UUID,
transactions: ReadonlyArray<TransactionEntity>, transactions: ReadonlyArray<ActualTransaction>,
): Promise<ImportTransactionsResponse> { ): Promise<ImportTransactionsResponse> {
return await actual.importTransactions(accountId, transactions) return actual.importTransactions(accountId, transactions)
} }
async shutdown() { async shutdown(): Promise<void> {
return await actual.shutdown() return actual.shutdown()
}
private static async downloadBudget(): Promise<void> {
await actual.downloadBudget(ACTUAL_SYNC_ID)
logger.info(`Downloaded budget`)
} }
} }

View File

@ -9,6 +9,12 @@ export type TokenResponse = {
expires_at: Dayjs expires_at: Dayjs
} }
export type TokenResponseRaw = {
key: TokenResponse["key"]
token: TokenResponse["token"]
expires_at: string
}
export type TokenKey = "access-token" | "refresh-token" export type TokenKey = "access-token" | "refresh-token"
export function createDb(filename: string) { export function createDb(filename: string) {
@ -57,7 +63,7 @@ function insert(
db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run( db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run(
key, key,
token, token,
dayjs().add(expiresIn, "seconds"), dayjs().add(expiresIn, "seconds").toISOString(),
) )
} }
@ -65,9 +71,14 @@ export function fetchToken(
db: Database.Database, db: Database.Database,
tokenKey: TokenKey, tokenKey: TokenKey,
): TokenResponse | null { ): TokenResponse | null {
const response = db
.prepare("SELECT * FROM tokens WHERE key = ?")
.get(tokenKey) as TokenResponseRaw | null
return ( return (
(db response && {
.prepare("SELECT * FROM tokens WHERE 'key' = ?") ...response,
.get(tokenKey) as TokenResponse) ?? null expires_at: dayjs(response.expires_at),
}
) )
} }

View File

@ -3,8 +3,13 @@ import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts"
import logger from "@/logger.ts" import logger from "@/logger.ts"
import dayjs from "dayjs" import dayjs from "dayjs"
import { Database } from "better-sqlite3" 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 * as Api from "./sparebank1Api.ts"
import { toISODateString } from "@/date.ts"
export interface OAuthTokenResponse { export interface OAuthTokenResponse {
access_token: string access_token: string
@ -17,21 +22,25 @@ export interface OAuthTokenResponse {
export interface Transaction { export interface Transaction {
id: string id: string
date: string nonUniqueId: string
amount: number date: number // Unix time
description: string amount: number // Amount in NOK
cleanedDescription: string cleanedDescription: string
remoteAccountName: string remoteAccountName: string
[key: string]: string | number | boolean | unknown [key: string]: string | number | boolean | unknown
} }
export interface TransactionResponse {
transactions: ReadonlyArray<Transaction>
}
export type Bank = Sparebank1 export type Bank = Sparebank1
export interface Sparebank1 { export interface Sparebank1 {
transactionsPastDay: ( transactionsPastDay: (
accountKeys: ReadonlyArray<string> | string, accountKeys: ReadonlyArray<string> | string,
) => Promise<ReadonlyArray<Transaction>> ) => Promise<TransactionResponse>
} }
export class Sparebank1Impl implements Sparebank1 { export class Sparebank1Impl implements Sparebank1 {
@ -53,11 +62,13 @@ export class Sparebank1Impl implements Sparebank1 {
} }
private isValidToken(tokenResponse: TokenResponse): boolean { 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<string> { private async getRefreshToken(): Promise<string> {
const tokenResponse = fetchToken(this.db, "refresh-token") const tokenResponse = fetchToken(this.db, "refresh-token")
logger.debug(`Database returned refresh token: '%o'`, tokenResponse)
if (!tokenResponse) { if (!tokenResponse) {
return BANK_INITIAL_REFRESH_TOKEN return BANK_INITIAL_REFRESH_TOKEN
} else if (this.isValidToken(tokenResponse)) { } else if (this.isValidToken(tokenResponse)) {
@ -69,10 +80,13 @@ export class Sparebank1Impl implements Sparebank1 {
async fetchNewTokens(): Promise<OAuthTokenResponse> { async fetchNewTokens(): Promise<OAuthTokenResponse> {
const refreshToken = await this.getRefreshToken() const refreshToken = await this.getRefreshToken()
logger.debug(`Found refresh token '${refreshToken}'`)
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") throw logger.error({
err: new Error(`Failed to fetch refresh token: '${result.data}'`),
})
} }
const oAuthToken = result.data const oAuthToken = result.data
@ -82,33 +96,34 @@ export class Sparebank1Impl implements Sparebank1 {
async transactionsPastDay( async transactionsPastDay(
accountKeys: ReadonlyArray<string> | string, accountKeys: ReadonlyArray<string> | string,
): Promise<ReadonlyArray<Transaction>> { ): Promise<TransactionResponse> {
const today = dayjs() const today = dayjs()
const lastDay = today.subtract(1, "day") const lastDay = today.subtract(1, "day")
const queries = new URLSearchParams({ const queries = new URLSearchParams({
// TODO allow multiple accountKeys // TODO allow multiple accountKeys
accountKey: accountKey:
typeof accountKeys === "string" ? accountKeys : accountKeys[0], typeof accountKeys === "string" ? accountKeys : accountKeys[0],
fromDate: lastDay.toString(), fromDate: toISODateString(lastDay),
toDate: today.toString(), toDate: toISODateString(today),
}) })
const accessToken = await this.getAccessToken() const accessToken = await this.getAccessToken()
const response = await fetch( logger.debug(`Found access token '${accessToken}'`)
`${Sparebank1Impl.baseUrl}/transactions?${queries}`, const url = `${Sparebank1Impl.baseUrl}/personal/banking/transactions?${queries}`
{ logger.debug(`Sending GET request to '${url}'`)
const response = await fetch(url, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.sparebank1.v1+json;charset=utf-8",
}, },
}, })
) logger.debug(`Received response with status '${response.status}'`)
if (response.ok) { if (response.ok) {
return response.json() return response.json()
} else { } else {
logger.warn( logger.warn(await response.json())
`transactionsPastDay returned a ${response.status} with the text ${response.statusText}`, return { transactions: [] }
)
return []
} }
} }
} }

View File

@ -1,5 +1,6 @@
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 { OAuthTokenResponse } from "@/bank/sparebank1.ts" import { OAuthTokenResponse } from "@/bank/sparebank1.ts"
import logger from "@/logger.ts"
const baseUrl = "https://api.sparebank1.no" const baseUrl = "https://api.sparebank1.no"
@ -24,9 +25,17 @@ export async function refreshToken(
refresh_token: refreshToken, refresh_token: refreshToken,
grant_type: "refresh_token", 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) { if (!response.ok) {
return failure(response.statusText) return failure(await response.text())
} }
return success(await response.json()) return success(await response.json())
} }

5
src/date.ts Normal file
View File

@ -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")}`
}

View File

@ -48,7 +48,8 @@ export async function daily(actual: Actual, bank: Bank): Promise<void> {
async function fetchTransactionsFromPastDay( async function fetchTransactionsFromPastDay(
bank: Bank, bank: Bank,
): Promise<ReadonlyArray<Transaction>> { ): Promise<ReadonlyArray<Transaction>> {
return bank.transactionsPastDay(BANK_ACCOUNT_IDS) const response = await bank.transactionsPastDay(BANK_ACCOUNT_IDS)
return response.transactions
} }
function createCacheDirIfMissing(): void { 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<void> { async function main(): Promise<void> {
logger.info("Starting application") logger.info("Starting application")
createCacheDirIfMissing() createCacheDirIfMissing()
const actual = await ActualImpl.init() const actual = await ActualImpl.init()
const databaseFileName = "default.sqlite" const databaseFileName = "default.sqlite" // TODO move name to env
const db = createDb(databaseFileName) const db = createDb(databaseFileName)
logger.info(`Started SQLlite database with filename="${databaseFileName}"`) logger.info(`Started SQLlite database with filename="${databaseFileName}"`)
logger.info("Waiting for CRON job to start") logger.info("Waiting for CRON job to start")
cronJobDaily(async () => { // cronJobDaily(async () => {
logger.info("Running daily job") logger.info("Running daily job")
await daily(actual, new Sparebank1Impl(db)) await daily(actual, new Sparebank1Impl(db))
logger.info("Finished daily job") logger.info("Finished daily job")
}) // })
// logger.info("Shutting down") logger.info("Shutting down")
// await actual.shutdown() await actual.shutdown()
// db.close() db.close()
} }
void main() void main()

View File

@ -1,21 +1,22 @@
import type { Transaction } from "@/bank/sparebank1.ts" 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 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( export function bankTransactionIntoActualTransaction(
transaction: Transaction, transaction: Transaction,
accountId: UUID, accountId: UUID,
): TransactionEntity { ): ActualTransaction {
return { return {
id: transaction.id, id: transaction.id,
// Transactions with the same id will be ignored // Transactions with the same id will be ignored
imported_id: transaction.id, imported_id: transaction.nonUniqueId,
account: accountId, account: accountId,
// The value without decimals // The value without decimals
amount: transaction.amount * 100, amount: transaction.amount * 100,
date: transaction.date, date: toISODateString(dayjs(transaction.date)),
payee: transaction.description, payee_name: transaction.cleanedDescription,
} }
} }

View File

@ -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 { export class BankStub implements Bank {
async transactionsPastDay( async transactionsPastDay(
_accountIds: ReadonlyArray<string> | string, _accountIds: ReadonlyArray<string> | string,
): Promise<ReadonlyArray<Transaction>> { ): Promise<TransactionResponse> {
const someFields = { const someFields = {
date: "2019-08-20", date: dayjs("2019-08-20").unix(),
description: "Test transaction",
cleanedDescription: "Test transaction", cleanedDescription: "Test transaction",
remoteAccountName: "Test account", remoteAccountName: "Test account",
} }
return [ return {
transactions: [
{ {
id: "1", id: "1",
nonUniqueId: "1",
amount: 100, amount: 100,
...someFields, ...someFields,
}, },
{ {
id: "2", id: "2",
nonUniqueId: "2",
amount: 200, amount: 200,
...someFields, ...someFields,
}, },
{ {
id: "3", id: "3",
nonUniqueId: "3",
amount: -50, amount: -50,
...someFields, ...someFields,
}, },
] ],
}
} }
} }