From 01af64349e6c665bc2cf915118ec1043c16a6d54 Mon Sep 17 00:00:00 2001 From: Martin Berg Alstad Date: Sun, 17 Nov 2024 22:27:29 +0100 Subject: [PATCH] Added: - CronJob - interface and simple impl for Actual - interface and simple impl for Sparebank1 - Mappings between sparebank1 transactions and actual transactions - Requires type keyword on type imports --- src/actual.ts | 48 +++++++++++++++++++++++++++++++++++++++ src/cron.ts | 15 ++++++++++++ src/main.ts | 58 +++++++++++++++++++++-------------------------- src/mappings.ts | 22 ++++++++++++++++++ src/sparebank1.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++-- tsconfig.json | 1 + 6 files changed, 167 insertions(+), 34 deletions(-) create mode 100644 src/mappings.ts diff --git a/src/actual.ts b/src/actual.ts index 1c75750..cf232e4 100644 --- a/src/actual.ts +++ b/src/actual.ts @@ -5,6 +5,54 @@ import { 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" + +export interface Actual { + importTransactions: ( + accountId: UUID, + transactions: ReadonlyArray, + ) => Promise + + shutdown: () => Promise +} + +export interface Message { + message: string +} + +export interface ImportTransactionsResponse { + errors?: Message[] + added: number + updated: number +} + +export class ActualImpl implements Actual { + private constructor() {} + + static async init(): Promise { + 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, + }) + return new ActualImpl() + } + + async importTransactions( + accountId: UUID, + transactions: ReadonlyArray, + ): Promise { + return await actual.importTransactions(accountId, transactions) + } + + async shutdown() { + return await actual.shutdown() + } +} export async function init() { return await actual.init({ diff --git a/src/cron.ts b/src/cron.ts index e69de29..6799f8f 100644 --- a/src/cron.ts +++ b/src/cron.ts @@ -0,0 +1,15 @@ +import { CronJob } from "cron" + +/** + * Run a function every day at 1 AM, Oslo time. + * @param onTick Function to run. + * @returns CronJob instance. + */ +export function cronJobDaily(onTick: () => Promise): CronJob { + return CronJob.from({ + cronTime: "0 0 1 * * *", + onTick, + start: true, + timeZone: "Europe/Oslo", + }) +} diff --git a/src/main.ts b/src/main.ts index 76ff898..a6d0d17 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,39 +1,33 @@ -// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts -import { downloadBudget, getAccounts, init, shutdown } from "@/actual.ts" -import * as actual from "@actual-app/api" +import { type Actual, ActualImpl } from "@/actual.ts" +import { cronJobDaily } from "@/cron.ts" +import { type Bank, Sparebank1Impl } from "@/sparebank1.ts" +import { transactionIntoActualTransaction } from "@/mappings.ts" -// TODO actual api does not work with Deno or Bun, because of better-sqlite3. Use Node LTS ☹ -async function main() { - console.log("Before init") - await init() - console.log("After init") - - console.log("Downloading budget") - await downloadBudget() - console.log("Downloaded budget") - - await actual.getBudgetMonth("2024-11") - - actual - .getTransactions( - "8e54a5d9-2155-47ff-9b5e-3f87415c2d10", - new Date("01-01-2024"), - new Date("12-12-2024"), +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", ) - .then((transactions) => { - console.log("Transactions", transactions) - }) - .catch((error) => { - console.error("Error", error) - }) - console.log("Getting accounts") - const accounts = await getAccounts() - console.log("Accounts", accounts) + // TODO account? id or name? + const actualTransactions = transactions.map((transaction) => + transactionIntoActualTransaction(transaction, ""), + ) - console.log("Before shutdown") - await shutdown() - console.log("After shutdown") + // 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) + } +} + +async function main(): Promise { + const actual = await ActualImpl.init() + cronJobDaily(await daily(actual, new Sparebank1Impl())) + // await actual.shutdown() } void main() diff --git a/src/mappings.ts b/src/mappings.ts new file mode 100644 index 0000000..ec523cc --- /dev/null +++ b/src/mappings.ts @@ -0,0 +1,22 @@ +import type { Transaction } from "@/sparebank1.ts" +import type { TransactionEntity } from "@actual-app/api/@types/loot-core/types/models" + +// TODO more fields / correct fields? +export function transactionIntoActualTransaction( + transaction: Transaction, + account: string, +): TransactionEntity { + return { + id: transaction.id, + account, + amount: transaction.amount, + date: transaction.date, + payee: transaction.description, + } +} + +// TODO take the account from the bank and match it to the actual account +// Use ENV +export function bankAccountIntoActualAccount(account: string): string { + throw new Error("Not implemented") +} diff --git a/src/sparebank1.ts b/src/sparebank1.ts index 671d6f0..a2ef96b 100644 --- a/src/sparebank1.ts +++ b/src/sparebank1.ts @@ -4,11 +4,64 @@ import { SPAREBANK1_OAUTH_STATE, } from "../config.ts" -async function authorize() { - await fetch(`https://api.sparebank1.no/oauth/authorize? +// TODO move types +export interface OAuthTokenResponse { + access_token: string + expires_in: number + refresh_token_expires_in: number + refresh_token_absolute_expires_in: number + token_type: "Bearer" + refresh_token: string +} + +export interface Transaction { + id: string + date: string + amount: number + description: string + cleanedDescription: string + remoteAccountName: string + + [key: string]: string | number | boolean | unknown +} + +export type Bank = Sparebank1 + +export interface Sparebank1 { + accessToken: () => Promise + refreshToken: (refreshToken: string) => Promise + + transactionsPastDay: ( + accountKeys: ReadonlyArray | string, + accessToken: string, + ) => Promise> +} + +export class Sparebank1Impl implements Sparebank1 { + private baseUrl = "https://api.sparebank1.no" + + 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`) + + if (response.ok) { + return await response.json() + } + throw new Error(`Failed to get access token. ${response.statusText}`) + } + + async refreshToken(refreshToken: string): Promise { + throw new Error("Not implemented") + } + + async transactionsPastDay( + accountKeys: ReadonlyArray | string, + accessToken: string, + ): Promise> { + throw new Error("Not implemented") + } } diff --git a/tsconfig.json b/tsconfig.json index 73cc157..4362e91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "strict": true, "skipLibCheck": true, "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, "paths": { "@/*": [ "./src/*"