🎉 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 {
importTransactions: (
accountId: UUID,
transactions: ReadonlyArray<TransactionEntity>,
transactions: ReadonlyArray<ActualTransaction>,
) => Promise<ImportTransactionsResponse>
shutdown: () => Promise<void>
}
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<TransactionEntity>,
transactions: ReadonlyArray<ActualTransaction>,
): Promise<ImportTransactionsResponse> {
return await actual.importTransactions(accountId, transactions)
return actual.importTransactions(accountId, transactions)
}
async shutdown() {
return await actual.shutdown()
async shutdown(): Promise<void> {
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
}
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),
}
)
}

View File

@ -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<Transaction>
}
export type Bank = Sparebank1
export interface Sparebank1 {
transactionsPastDay: (
accountKeys: ReadonlyArray<string> | string,
) => Promise<ReadonlyArray<Transaction>>
) => Promise<TransactionResponse>
}
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<string> {
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<OAuthTokenResponse> {
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> | string,
): Promise<ReadonlyArray<Transaction>> {
): Promise<TransactionResponse> {
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: [] }
}
}
}

View File

@ -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())
}

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(
bank: Bank,
): Promise<ReadonlyArray<Transaction>> {
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<void> {
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()

View File

@ -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,
}
}

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 {
async transactionsPastDay(
_accountIds: ReadonlyArray<string> | string,
): Promise<ReadonlyArray<Transaction>> {
): Promise<TransactionResponse> {
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,
},
],
}
}
}