- Env key to be more generic
- Cache directory to .cache
- Multiply bank amount by 100 to get correct conversion rate

Removed:
- Unused functions in Actual.ts

Added:
- Test for main functionality
- BankStub for testing
- Added imported_id to Actual Transaction to avoid duplicates
This commit is contained in:
Martin Berg Alstad 2024-12-01 20:48:42 +01:00
parent cc325b9f08
commit 9ed0a19393
Signed by: martials
GPG Key ID: DF629A90917D1319
9 changed files with 88 additions and 50 deletions

View File

@ -2,9 +2,9 @@ ACTUAL_BUDGET_ID=your-budget-id
ACTUAL_SYNC_ID=your-sync-id
ACTUAL_SERVER_URL=your-server-url
ACTUAL_PASSWORD=your-password
# Sparebank1
SPAREBANK1_OAUTH_CLIENT_ID=your-client-id
SPAREBANK1_OAUTH_CLIENT_SECRET=your-client-secret
SPAREBANK1_OAUTH_STATE=your-state
SPAREBANK1_OAUTH_REDIRECT_URI=your-redirect-uri
# Bank
BANK_OAUTH_CLIENT_ID=your-client-id
BANK_OAUTH_CLIENT_SECRET=your-client-secret
BANK_OAUTH_STATE=your-state
BANK_OAUTH_REDIRECT_URI=your-redirect-uri
BANK_ACCOUNT_IDS=your-account-id1,your-account-id2

2
.gitignore vendored
View File

@ -12,7 +12,7 @@ lerna-debug.log*
# Caches
data/cache/*
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)

View File

@ -7,19 +7,13 @@ export const ACTUAL_BUDGET_ID = getOrThrow("ACTUAL_BUDGET_ID")
export const ACTUAL_SYNC_ID = getOrThrow("ACTUAL_SYNC_ID")
export const ACTUAL_SERVER_URL = getOrThrow("ACTUAL_SERVER_URL")
export const ACTUAL_PASSWORD = getOrThrow("ACTUAL_PASSWORD")
export const ACTUAL_DATA_DIR = "data/cache"
export const ACTUAL_ACCOUNT_IDS = getArrayOrThrow("ACTUAL_ACCOUNT_IDS")
export const ACTUAL_DATA_DIR = ".cache"
export const SPAREBANK1_OAUTH_CLIENT_ID = getOrThrow(
"SPAREBANK1_OAUTH_CLIENT_ID",
)
export const SPAREBANK1_OAUTH_CLIENT_SECRET = getOrThrow(
"SPAREBANK1_OAUTH_CLIENT_SECRET",
)
export const SPAREBANK1_OAUTH_REDIRECT_URI = getOrThrow(
"SPAREBANK1_OAUTH_REDIRECT_URI",
)
export const SPAREBANK1_OAUTH_STATE = getOrThrow("SPAREBANK1_OAUTH_STATE")
export const BANK_OAUTH_CLIENT_ID = getOrThrow("BANK_OAUTH_CLIENT_ID")
export const BANK_OAUTH_CLIENT_SECRET = getOrThrow("BANK_OAUTH_CLIENT_SECRET")
export const BANK_OAUTH_REDIRECT_URI = getOrThrow("BANK_OAUTH_REDIRECT_URI")
export const BANK_OAUTH_STATE = getOrThrow("BANK_OAUTH_STATE")
export const BANK_ACCOUNT_IDS = getArrayOrThrow("BANK_ACCOUNT_IDS")
function getOrThrow(key: string): string {

View File

@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"start": "node --import=tsx ./src/main.ts | pino-pretty",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "node --test --experimental-strip-types ./tests/**",
"format": "prettier --write \"./**/*.{js,mjs,ts,md,json}\""
},
"keywords": [],

View File

@ -3,7 +3,6 @@ import {
ACTUAL_DATA_DIR,
ACTUAL_PASSWORD,
ACTUAL_SERVER_URL,
ACTUAL_SYNC_ID,
} from "../config.ts"
import type { TransactionEntity } from "@actual-app/api/@types/loot-core/types/models"
import { type UUID } from "node:crypto"
@ -53,28 +52,3 @@ export class ActualImpl implements Actual {
return await actual.shutdown()
}
}
export async function init() {
return await actual.init({
// Budget data will be cached locally here, in subdirectories for each file.
dataDir: ACTUAL_DATA_DIR,
// This is the URL of your running server
serverURL: ACTUAL_SERVER_URL,
// This is the password you use to log into the server
password: ACTUAL_PASSWORD,
})
}
export async function downloadBudget() {
const something = await actual.downloadBudget(ACTUAL_SYNC_ID)
console.log("downloadBudget", something)
return something
}
export async function getAccounts() {
return await actual.getAccounts()
}
export async function shutdown() {
await actual.shutdown()
}

View File

@ -1,14 +1,15 @@
import { type Actual, ActualImpl } from "@/actual.ts"
import { cronJobDaily } from "@/cron.ts"
import { type Bank, Sparebank1Impl, type Transaction } from "@/sparebank1.ts"
import { transactionIntoActualTransaction } from "@/mappings.ts"
import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
import { ACTUAL_ACCOUNT_IDS, BANK_ACCOUNT_IDS } from "../config.ts"
import logger from "pino"
import type { UUID } from "node:crypto"
// TODO Transports api for pino https://github.com/pinojs/pino/blob/HEAD/docs/transports.md
// TODO create .cache if missing
async function daily(actual: Actual, bank: Bank): Promise<void> {
export async function daily(actual: Actual, bank: Bank): Promise<void> {
// Fetch transactions from the bank
const transactions = await fetchTransactionsFromPastDay(bank)
logger().info(`Fetched ${transactions.length} transactions`)
@ -16,12 +17,14 @@ async function daily(actual: Actual, bank: Bank): Promise<void> {
// TODO multiple accounts
const accountId = ACTUAL_ACCOUNT_IDS[0] as UUID
const actualTransactions = transactions.map((transaction) =>
transactionIntoActualTransaction(transaction, accountId),
bankTransactionIntoActualTransaction(transaction, accountId),
)
// TODO Import transactions into Actual
// If multiple accounts, loop over them
// Get account ID from mapper
// TODO TypeError: Cannot read properties of undefined (reading 'timestamp')
await actual.importTransactions(accountId, actualTransactions)
}
@ -36,12 +39,15 @@ async function fetchTransactionsFromPastDay(
async function main(): Promise<void> {
logger().info("Starting application")
const actual = await ActualImpl.init()
logger().info("Initialized Actual Budget API")
cronJobDaily(async () => {
logger().info("Running daily job")
await daily(actual, new Sparebank1Impl())
logger().info("Finished daily job")
})
logger().info("Shutting down")
// logger().info("Shutting down")
// await actual.shutdown()
}

View File

@ -3,14 +3,17 @@ import type { TransactionEntity } from "@actual-app/api/@types/loot-core/types/m
import type { UUID } from "node:crypto"
// TODO more fields / correct fields?
export function transactionIntoActualTransaction(
export function bankTransactionIntoActualTransaction(
transaction: Transaction,
accountId: UUID,
): TransactionEntity {
return {
id: transaction.id,
// Transactions with the same id will be ignored
imported_id: transaction.id,
account: accountId,
amount: transaction.amount,
// The value without decimals
amount: transaction.amount * 100,
date: transaction.date,
payee: transaction.description,
}

12
tests/main.test.ts Normal file
View File

@ -0,0 +1,12 @@
import { describe, it } from "node:test"
import { daily } from "@/main.ts"
import { ActualImpl } from "@/actual.ts"
import { BankStub } from "./stubs/bankStub.ts"
import assert from "node:assert"
describe("Main logic of the application", () => {
it("should import the transactions to Actual Budget", async () => {
await daily(await ActualImpl.init(), new BankStub())
assert.ok(true)
})
})

49
tests/stubs/bankStub.ts Normal file
View File

@ -0,0 +1,49 @@
import type { Bank, OAuthTokenResponse, Transaction } from "@/sparebank1.ts"
const tokenResponse: OAuthTokenResponse = {
access_token: "my_access_token",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "my_refresh_token",
refresh_token_expires_in: 3600,
refresh_token_absolute_expires_in: 3600,
}
export class BankStub implements Bank {
async accessToken(): Promise<OAuthTokenResponse> {
return tokenResponse
}
async refreshToken(_unused: string): Promise<OAuthTokenResponse> {
return tokenResponse
}
async transactionsPastDay(
_accountIds: ReadonlyArray<string> | string,
_accessToken: string,
): Promise<ReadonlyArray<Transaction>> {
const someFields = {
date: new Date().toDateString(),
description: "Test transaction",
cleanedDescription: "Test transaction",
remoteAccountName: "Test account",
}
return [
{
id: "1",
amount: 100,
...someFields,
},
{
id: "2",
amount: 200,
...someFields,
},
{
id: "3",
amount: -50,
...someFields,
},
]
}
}