Compare commits

..

No commits in common. "9ed0a19393474e0149c4c909af6837c295fa9925" and "01af64349e6c665bc2cf915118ec1043c16a6d54" have entirely different histories.

11 changed files with 85 additions and 230 deletions

View File

@ -2,9 +2,8 @@ ACTUAL_BUDGET_ID=your-budget-id
ACTUAL_SYNC_ID=your-sync-id ACTUAL_SYNC_ID=your-sync-id
ACTUAL_SERVER_URL=your-server-url ACTUAL_SERVER_URL=your-server-url
ACTUAL_PASSWORD=your-password ACTUAL_PASSWORD=your-password
# Bank # Sparebank1
BANK_OAUTH_CLIENT_ID=your-client-id SPAREBANK1_OAUTH_CLIENT_ID=your-client-id
BANK_OAUTH_CLIENT_SECRET=your-client-secret SPAREBANK1_OAUTH_CLIENT_SECRET=your-client-secret
BANK_OAUTH_STATE=your-state SPAREBANK1_OAUTH_STATE=your-state
BANK_OAUTH_REDIRECT_URI=your-redirect-uri SPAREBANK1_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 # Caches
.cache data/cache/*
# Diagnostic reports (https://nodejs.org/api/report.html) # 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_SYNC_ID = getOrThrow("ACTUAL_SYNC_ID")
export const ACTUAL_SERVER_URL = getOrThrow("ACTUAL_SERVER_URL") export const ACTUAL_SERVER_URL = getOrThrow("ACTUAL_SERVER_URL")
export const ACTUAL_PASSWORD = getOrThrow("ACTUAL_PASSWORD") export const ACTUAL_PASSWORD = getOrThrow("ACTUAL_PASSWORD")
export const ACTUAL_ACCOUNT_IDS = getArrayOrThrow("ACTUAL_ACCOUNT_IDS") export const ACTUAL_DATA_DIR = "data/cache"
export const ACTUAL_DATA_DIR = ".cache"
export const BANK_OAUTH_CLIENT_ID = getOrThrow("BANK_OAUTH_CLIENT_ID") export const SPAREBANK1_OAUTH_CLIENT_ID = getOrThrow(
export const BANK_OAUTH_CLIENT_SECRET = getOrThrow("BANK_OAUTH_CLIENT_SECRET") "SPAREBANK1_OAUTH_CLIENT_ID",
export const BANK_OAUTH_REDIRECT_URI = getOrThrow("BANK_OAUTH_REDIRECT_URI") )
export const BANK_OAUTH_STATE = getOrThrow("BANK_OAUTH_STATE") export const SPAREBANK1_OAUTH_CLIENT_SECRET = getOrThrow(
export const BANK_ACCOUNT_IDS = getArrayOrThrow("BANK_ACCOUNT_IDS") "SPAREBANK1_OAUTH_CLIENT_SECRET",
)
export const SPAREBANK1_OAUTH_REDIRECT_URI = getOrThrow(
"SPAREBANK1_OAUTH_REDIRECT_URI",
)
export const SPAREBANK1_OAUTH_STATE = getOrThrow("SPAREBANK1_OAUTH_STATE")
function getOrThrow(key: string): string { function getOrThrow(key: string): string {
const value = process.env[key] const value = process.env[key]
assert(value, `Missing environment variable: ${key}`) assert(value, `Missing environment variable: ${key}`)
return value return value
} }
function getArrayOrThrow(key: string): ReadonlyArray<string> {
return getOrThrow(key).split(",")
}

View File

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

93
pnpm-lock.yaml generated
View File

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

View File

@ -3,6 +3,7 @@ import {
ACTUAL_DATA_DIR, ACTUAL_DATA_DIR,
ACTUAL_PASSWORD, ACTUAL_PASSWORD,
ACTUAL_SERVER_URL, ACTUAL_SERVER_URL,
ACTUAL_SYNC_ID,
} from "../config.ts" } from "../config.ts"
import type { TransactionEntity } from "@actual-app/api/@types/loot-core/types/models" import type { TransactionEntity } from "@actual-app/api/@types/loot-core/types/models"
import { type UUID } from "node:crypto" import { type UUID } from "node:crypto"
@ -52,3 +53,28 @@ export class ActualImpl implements Actual {
return await actual.shutdown() 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,53 +1,32 @@
import { type Actual, ActualImpl } from "@/actual.ts" import { type Actual, ActualImpl } from "@/actual.ts"
import { cronJobDaily } from "@/cron.ts" import { cronJobDaily } from "@/cron.ts"
import { type Bank, Sparebank1Impl, type Transaction } from "@/sparebank1.ts" import { type Bank, Sparebank1Impl } from "@/sparebank1.ts"
import { bankTransactionIntoActualTransaction } from "@/mappings.ts" import { transactionIntoActualTransaction } 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 async function daily(actual: Actual, bank: Bank): Promise<() => Promise<void>> {
// TODO create .cache if missing return async () => {
console.log("Wake up! It's 1 AM!")
export async function daily(actual: Actual, bank: Bank): Promise<void> {
// Fetch transactions from the bank // Fetch transactions from the bank
const transactions = await fetchTransactionsFromPastDay(bank) const transactions = await bank.transactionsPastDay(
logger().info(`Fetched ${transactions.length} transactions`) "my_account",
"my_access_token",
)
// TODO multiple accounts // TODO account? id or name?
const accountId = ACTUAL_ACCOUNT_IDS[0] as UUID
const actualTransactions = transactions.map((transaction) => const actualTransactions = transactions.map((transaction) =>
bankTransactionIntoActualTransaction(transaction, accountId), transactionIntoActualTransaction(transaction, ""),
) )
// TODO Import transactions into Actual // TODO Import transactions into Actual
// If multiple accounts, loop over them // If multiple accounts, loop over them
// Get account ID from mapper // Get account ID from mapper
await actual.importTransactions("a-b-c-d-e", actualTransactions)
// 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> { async function main(): Promise<void> {
logger().info("Starting application")
const actual = await ActualImpl.init() const actual = await ActualImpl.init()
logger().info("Initialized Actual Budget API") cronJobDaily(await daily(actual, new Sparebank1Impl()))
cronJobDaily(async () => {
logger().info("Running daily job")
await daily(actual, new Sparebank1Impl())
logger().info("Finished daily job")
})
// logger().info("Shutting down")
// await actual.shutdown() // await actual.shutdown()
} }

View File

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

View File

@ -1,3 +1,9 @@
import {
SPAREBANK1_OAUTH_CLIENT_ID,
SPAREBANK1_OAUTH_REDIRECT_URI,
SPAREBANK1_OAUTH_STATE,
} from "../config.ts"
// TODO move types // TODO move types
export interface OAuthTokenResponse { export interface OAuthTokenResponse {
access_token: string access_token: string
@ -34,14 +40,18 @@ export interface Sparebank1 {
export class Sparebank1Impl implements Sparebank1 { export class Sparebank1Impl implements Sparebank1 {
private baseUrl = "https://api.sparebank1.no" private baseUrl = "https://api.sparebank1.no"
// TODO remove?
async accessToken(): Promise<OAuthTokenResponse> { async accessToken(): Promise<OAuthTokenResponse> {
throw new Error("Not implemented") 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`)
// if (response.ok) { if (response.ok) {
// return await response.json() return await response.json()
// } }
// throw new Error(`Failed to get access token. ${response.statusText}`) throw new Error(`Failed to get access token. ${response.statusText}`)
} }
async refreshToken(refreshToken: string): Promise<OAuthTokenResponse> { async refreshToken(refreshToken: string): Promise<OAuthTokenResponse> {

View File

@ -1,12 +0,0 @@
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)
})
})

View File

@ -1,49 +0,0 @@
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,
},
]
}
}