- 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
This commit is contained in:
Martin Berg Alstad 2024-11-17 22:27:29 +01:00
parent 90bcf94f14
commit 01af64349e
Signed by: martials
GPG Key ID: DF629A90917D1319
6 changed files with 167 additions and 34 deletions

View File

@ -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<TransactionEntity>,
) => Promise<ImportTransactionsResponse>
shutdown: () => Promise<void>
}
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<Actual> {
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<TransactionEntity>,
): Promise<ImportTransactionsResponse> {
return await actual.importTransactions(accountId, transactions)
}
async shutdown() {
return await actual.shutdown()
}
}
export async function init() {
return await actual.init({

View File

@ -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<void>): CronJob {
return CronJob.from({
cronTime: "0 0 1 * * *",
onTick,
start: true,
timeZone: "Europe/Oslo",
})
}

View File

@ -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<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",
)
.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<void> {
const actual = await ActualImpl.init()
cronJobDaily(await daily(actual, new Sparebank1Impl()))
// await actual.shutdown()
}
void main()

22
src/mappings.ts Normal file
View File

@ -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")
}

View File

@ -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<OAuthTokenResponse>
refreshToken: (refreshToken: string) => Promise<OAuthTokenResponse>
transactionsPastDay: (
accountKeys: ReadonlyArray<string> | string,
accessToken: string,
) => Promise<ReadonlyArray<Transaction>>
}
export class Sparebank1Impl implements Sparebank1 {
private baseUrl = "https://api.sparebank1.no"
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`)
if (response.ok) {
return await response.json()
}
throw new Error(`Failed to get access token. ${response.statusText}`)
}
async refreshToken(refreshToken: string): Promise<OAuthTokenResponse> {
throw new Error("Not implemented")
}
async transactionsPastDay(
accountKeys: ReadonlyArray<string> | string,
accessToken: string,
): Promise<ReadonlyArray<Transaction>> {
throw new Error("Not implemented")
}
}

View File

@ -8,6 +8,7 @@
"strict": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"paths": {
"@/*": [
"./src/*"