diff --git a/.env.example b/.env.example index 3e1f322..1dc2bde 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 846e966..a708518 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ lerna-debug.log* # Caches -data/cache/* +.cache # Diagnostic reports (https://nodejs.org/api/report.html) diff --git a/config.ts b/config.ts index 30a6589..a704e30 100644 --- a/config.ts +++ b/config.ts @@ -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 { diff --git a/package.json b/package.json index ad86c6c..0c63f62 100644 --- a/package.json +++ b/package.json @@ -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": [], diff --git a/src/actual.ts b/src/actual.ts index cf232e4..fc17dd4 100644 --- a/src/actual.ts +++ b/src/actual.ts @@ -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() -} diff --git a/src/main.ts b/src/main.ts index c45a0f9..bea417c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { +export async function daily(actual: Actual, bank: Bank): Promise { // 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 { // 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 { 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() } diff --git a/src/mappings.ts b/src/mappings.ts index 2d7a88f..4656356 100644 --- a/src/mappings.ts +++ b/src/mappings.ts @@ -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, } diff --git a/tests/main.test.ts b/tests/main.test.ts new file mode 100644 index 0000000..2da1d5f --- /dev/null +++ b/tests/main.test.ts @@ -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) + }) +}) diff --git a/tests/stubs/bankStub.ts b/tests/stubs/bankStub.ts new file mode 100644 index 0000000..d782bd3 --- /dev/null +++ b/tests/stubs/bankStub.ts @@ -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 { + return tokenResponse + } + + async refreshToken(_unused: string): Promise { + return tokenResponse + } + + async transactionsPastDay( + _accountIds: ReadonlyArray | string, + _accessToken: string, + ): Promise> { + 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, + }, + ] + } +}