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
This commit is contained in:
parent
90bcf94f14
commit
01af64349e
@ -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({
|
||||
|
15
src/cron.ts
15
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<void>): CronJob {
|
||||
return CronJob.from({
|
||||
cronTime: "0 0 1 * * *",
|
||||
onTick,
|
||||
start: true,
|
||||
timeZone: "Europe/Oslo",
|
||||
})
|
||||
}
|
58
src/main.ts
58
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<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
22
src/mappings.ts
Normal 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")
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
|
Loading…
x
Reference in New Issue
Block a user