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_SERVER_URL,
|
||||||
ACTUAL_SYNC_ID,
|
ACTUAL_SYNC_ID,
|
||||||
} from "../config.ts"
|
} 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() {
|
export async function init() {
|
||||||
return await actual.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 { type Actual, ActualImpl } from "@/actual.ts"
|
||||||
import { downloadBudget, getAccounts, init, shutdown } from "@/actual.ts"
|
import { cronJobDaily } from "@/cron.ts"
|
||||||
import * as actual from "@actual-app/api"
|
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 daily(actual: Actual, bank: Bank): Promise<() => Promise<void>> {
|
||||||
async function main() {
|
return async () => {
|
||||||
console.log("Before init")
|
console.log("Wake up! It's 1 AM!")
|
||||||
await init()
|
// Fetch transactions from the bank
|
||||||
console.log("After init")
|
const transactions = await bank.transactionsPastDay(
|
||||||
|
"my_account",
|
||||||
console.log("Downloading budget")
|
"my_access_token",
|
||||||
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"),
|
|
||||||
)
|
)
|
||||||
.then((transactions) => {
|
|
||||||
console.log("Transactions", transactions)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("Getting accounts")
|
// TODO account? id or name?
|
||||||
const accounts = await getAccounts()
|
const actualTransactions = transactions.map((transaction) =>
|
||||||
console.log("Accounts", accounts)
|
transactionIntoActualTransaction(transaction, ""),
|
||||||
|
)
|
||||||
|
|
||||||
console.log("Before shutdown")
|
// TODO Import transactions into Actual
|
||||||
await shutdown()
|
// If multiple accounts, loop over them
|
||||||
console.log("After shutdown")
|
// 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()
|
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,
|
SPAREBANK1_OAUTH_STATE,
|
||||||
} from "../config.ts"
|
} from "../config.ts"
|
||||||
|
|
||||||
async function authorize() {
|
// TODO move types
|
||||||
await fetch(`https://api.sparebank1.no/oauth/authorize?
|
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}&
|
client_id=${SPAREBANK1_OAUTH_CLIENT_ID}&
|
||||||
state=${SPAREBANK1_OAUTH_STATE}&
|
state=${SPAREBANK1_OAUTH_STATE}&
|
||||||
redirect_uri=${SPAREBANK1_OAUTH_REDIRECT_URI}&
|
redirect_uri=${SPAREBANK1_OAUTH_REDIRECT_URI}&
|
||||||
finInst=fid-smn&
|
finInst=fid-smn&
|
||||||
response_type=code`)
|
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,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user