🎉 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:
parent
9a00592a7a
commit
b61903f5c8
@ -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`)
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -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: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
5
src/date.ts
Normal 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")}`
|
||||
}
|
23
src/main.ts
23
src/main.ts
@ -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()
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user