Compare commits

..

2 Commits

Author SHA1 Message Date
9ed0a19393
Changed:
- 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
2024-12-01 20:48:42 +01:00
cc325b9f08
Added:
- Logging library
- Account Ids to .env
- Split logic in main function
2024-12-01 19:35:45 +01:00
11 changed files with 230 additions and 85 deletions

View File

@ -2,8 +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,21 +7,21 @@ 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 {
const value = process.env[key]
assert(value, `Missing environment variable: ${key}`)
return value
}
function getArrayOrThrow(key: string): ReadonlyArray<string> {
return getOrThrow(key).split(",")
}

View File

@ -4,8 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"start": "node --import=tsx ./src/main.ts",
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node --import=tsx ./src/main.ts | pino-pretty",
"test": "node --test --experimental-strip-types ./tests/**",
"format": "prettier --write \"./**/*.{js,mjs,ts,md,json}\""
},
"keywords": [],
@ -15,6 +15,7 @@
"@actual-app/api": "^24.11.0",
"cron": "^3.2.1",
"dotenv": "^16.4.5",
"pino": "^9.5.0",
"prettier": "^3.3.3"
},
"devDependencies": {

93
pnpm-lock.yaml generated
View File

@ -17,6 +17,9 @@ importers:
dotenv:
specifier: ^16.4.5
version: 16.4.5
pino:
specifier: ^9.5.0
version: 9.5.0
prettier:
specifier: ^3.3.3
version: 3.3.3
@ -190,6 +193,10 @@ packages:
'@types/node@22.9.0':
resolution: {integrity: sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@ -246,6 +253,10 @@ packages:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
fast-redact@3.5.0:
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==}
engines: {node: '>=6'}
fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
@ -315,9 +326,23 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
pino-std-serializers@7.0.0:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
pino@9.5.0:
resolution: {integrity: sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==}
hasBin: true
prebuild-install@7.1.2:
resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==}
engines: {node: '>=10'}
@ -328,9 +353,15 @@ packages:
engines: {node: '>=14'}
hasBin: true
process-warning@4.0.0:
resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==}
pump@3.0.2:
resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
@ -339,12 +370,20 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
semver@7.6.3:
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
engines: {node: '>=10'}
@ -356,6 +395,13 @@ packages:
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
sonic-boom@4.2.0:
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@ -370,6 +416,9 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
tsx@4.19.2:
resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==}
engines: {node: '>=18.0.0'}
@ -494,6 +543,8 @@ snapshots:
dependencies:
undici-types: 6.19.8
atomic-sleep@1.0.0: {}
base64-js@1.5.1: {}
better-sqlite3@9.6.0:
@ -570,6 +621,8 @@ snapshots:
expand-template@2.0.3: {}
fast-redact@3.5.0: {}
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
@ -624,10 +677,32 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
on-exit-leak-free@2.1.2: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
pino-abstract-transport@2.0.0:
dependencies:
split2: 4.2.0
pino-std-serializers@7.0.0: {}
pino@9.5.0:
dependencies:
atomic-sleep: 1.0.0
fast-redact: 3.5.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 2.0.0
pino-std-serializers: 7.0.0
process-warning: 4.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.0
thread-stream: 3.1.0
prebuild-install@7.1.2:
dependencies:
detect-libc: 2.0.3
@ -645,11 +720,15 @@ snapshots:
prettier@3.3.3: {}
process-warning@4.0.0: {}
pump@3.0.2:
dependencies:
end-of-stream: 1.4.4
once: 1.4.0
quick-format-unescaped@4.0.4: {}
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
@ -663,10 +742,14 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
real-require@0.2.0: {}
resolve-pkg-maps@1.0.0: {}
safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {}
semver@7.6.3: {}
simple-concat@1.0.1: {}
@ -677,6 +760,12 @@ snapshots:
once: 1.4.0
simple-concat: 1.0.1
sonic-boom@4.2.0:
dependencies:
atomic-sleep: 1.0.0
split2@4.2.0: {}
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@ -698,6 +787,10 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
thread-stream@3.1.0:
dependencies:
real-require: 0.2.0
tsx@4.19.2:
dependencies:
esbuild: 0.23.1

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,32 +1,53 @@
import { type Actual, ActualImpl } from "@/actual.ts"
import { cronJobDaily } from "@/cron.ts"
import { type Bank, Sparebank1Impl } from "@/sparebank1.ts"
import { transactionIntoActualTransaction } from "@/mappings.ts"
import { type Bank, Sparebank1Impl, type Transaction } from "@/sparebank1.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"
async function daily(actual: Actual, bank: Bank): Promise<() => Promise<void>> {
return async () => {
console.log("Wake up! It's 1 AM!")
// Fetch transactions from the bank
const transactions = await bank.transactionsPastDay(
"my_account",
"my_access_token",
)
// TODO Transports api for pino https://github.com/pinojs/pino/blob/HEAD/docs/transports.md
// TODO create .cache if missing
// TODO account? id or name?
const actualTransactions = transactions.map((transaction) =>
transactionIntoActualTransaction(transaction, ""),
)
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`)
// TODO Import transactions into Actual
// If multiple accounts, loop over them
// Get account ID from mapper
await actual.importTransactions("a-b-c-d-e", actualTransactions)
}
// TODO multiple accounts
const accountId = ACTUAL_ACCOUNT_IDS[0] as UUID
const actualTransactions = transactions.map((transaction) =>
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)
}
async function fetchTransactionsFromPastDay(
bank: Bank,
): Promise<ReadonlyArray<Transaction>> {
// TODO refresh token
const { access_token } = await bank.refreshToken("my_refresh_token")
return bank.transactionsPastDay(BANK_ACCOUNT_IDS, access_token)
}
async function main(): Promise<void> {
logger().info("Starting application")
const actual = await ActualImpl.init()
cronJobDaily(await daily(actual, new Sparebank1Impl()))
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")
// await actual.shutdown()
}

View File

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

View File

@ -1,9 +1,3 @@
import {
SPAREBANK1_OAUTH_CLIENT_ID,
SPAREBANK1_OAUTH_REDIRECT_URI,
SPAREBANK1_OAUTH_STATE,
} from "../config.ts"
// TODO move types
export interface OAuthTokenResponse {
access_token: string
@ -40,18 +34,14 @@ export interface Sparebank1 {
export class Sparebank1Impl implements Sparebank1 {
private baseUrl = "https://api.sparebank1.no"
// TODO remove?
async accessToken(): Promise<OAuthTokenResponse> {
const response = await fetch(`${this.baseUrl}/oauth/authorize?
client_id=${SPAREBANK1_OAUTH_CLIENT_ID}&
state=${SPAREBANK1_OAUTH_STATE}&
redirect_uri=${SPAREBANK1_OAUTH_REDIRECT_URI}&
finInst=fid-smn&
response_type=code`)
throw new Error("Not implemented")
if (response.ok) {
return await response.json()
}
throw new Error(`Failed to get access token. ${response.statusText}`)
// if (response.ok) {
// return await response.json()
// }
// throw new Error(`Failed to get access token. ${response.statusText}`)
}
async refreshToken(refreshToken: string): Promise<OAuthTokenResponse> {

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