🎉 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 {
|
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`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
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")}`
|
||||||
|
}
|
17
src/main.ts
17
src/main.ts
@ -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()
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user