diff --git a/.env.example b/.env.example index 2df3253..3e1f322 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,5 @@ ACTUAL_PASSWORD=your-password 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 \ No newline at end of file +SPAREBANK1_OAUTH_REDIRECT_URI=your-redirect-uri +BANK_ACCOUNT_IDS=your-account-id1,your-account-id2 \ No newline at end of file diff --git a/config.ts b/config.ts index 6d31188..30a6589 100644 --- a/config.ts +++ b/config.ts @@ -8,6 +8,7 @@ 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 SPAREBANK1_OAUTH_CLIENT_ID = getOrThrow( "SPAREBANK1_OAUTH_CLIENT_ID", @@ -19,9 +20,14 @@ export const SPAREBANK1_OAUTH_REDIRECT_URI = getOrThrow( "SPAREBANK1_OAUTH_REDIRECT_URI", ) export const SPAREBANK1_OAUTH_STATE = getOrThrow("SPAREBANK1_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 { + return getOrThrow(key).split(",") +} diff --git a/package.json b/package.json index 48074ae..ad86c6c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "start": "node --import=tsx ./src/main.ts", + "start": "node --import=tsx ./src/main.ts | pino-pretty", "test": "echo \"Error: no test specified\" && exit 1", "format": "prettier --write \"./**/*.{js,mjs,ts,md,json}\"" }, @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 819c977..58ef9a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/main.ts b/src/main.ts index a6d0d17..c45a0f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,32 +1,47 @@ import { type Actual, ActualImpl } from "@/actual.ts" import { cronJobDaily } from "@/cron.ts" -import { type Bank, Sparebank1Impl } from "@/sparebank1.ts" +import { type Bank, Sparebank1Impl, type Transaction } from "@/sparebank1.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" -async function daily(actual: Actual, bank: Bank): Promise<() => Promise> { - 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 account? id or name? - const actualTransactions = transactions.map((transaction) => - transactionIntoActualTransaction(transaction, ""), - ) +async function daily(actual: Actual, bank: Bank): Promise { + // 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) => + transactionIntoActualTransaction(transaction, accountId), + ) + + // TODO Import transactions into Actual + // If multiple accounts, loop over them + // Get account ID from mapper + await actual.importTransactions(accountId, actualTransactions) +} + +async function fetchTransactionsFromPastDay( + bank: Bank, +): Promise> { + // TODO refresh token + const { access_token } = await bank.refreshToken("my_refresh_token") + return bank.transactionsPastDay(BANK_ACCOUNT_IDS, access_token) } async function main(): Promise { + logger().info("Starting application") const actual = await ActualImpl.init() - 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() } diff --git a/src/mappings.ts b/src/mappings.ts index ec523cc..2d7a88f 100644 --- a/src/mappings.ts +++ b/src/mappings.ts @@ -1,14 +1,15 @@ 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( transaction: Transaction, - account: string, + accountId: UUID, ): TransactionEntity { return { id: transaction.id, - account, + account: accountId, amount: transaction.amount, date: transaction.date, payee: transaction.description, diff --git a/src/sparebank1.ts b/src/sparebank1.ts index a2ef96b..0f9d0de 100644 --- a/src/sparebank1.ts +++ b/src/sparebank1.ts @@ -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 { - 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 {