├── taskfile.yaml ├── .github ├── CODEOWNERS └── workflows │ └── ci.yml ├── .editorconfig ├── jest.config.js ├── dockerfile ├── package.json ├── tsconfig.json ├── docker-compose.yaml ├── src ├── utils │ ├── transaction-transformer.ts │ └── env-validator.ts ├── services │ ├── akahu-service.ts │ └── actual-service.ts └── index.ts ├── tests ├── akahu-service.test.ts ├── actual-service.test.ts ├── transaction-transformer.test.ts └── env-validator.test.ts ├── LICENSE ├── .renovaterc.json5 ├── .gitignore ├── README.md └── CHANGELOG.md /taskfile.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | tasks: 3 | build: 4 | cmds: 5 | - tsc 6 | default: 7 | deps: [build] 8 | cmds: 9 | - node dist/index.js 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 2 | * @scottmckendry 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indest_style = space 3 | indent_size = 4 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.yml] 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/tests/**/*.test.ts'], 5 | moduleFileExtensions: ['ts', 'js', 'json'], 6 | verbose: true, 7 | reporters: [ 8 | 'default', 9 | ['jest-junit', { outputDirectory: 'test-results', outputName: 'results.xml' }] 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | # build 2 | FROM node:24-slim AS builder 3 | USER node 4 | 5 | WORKDIR /build 6 | COPY --chown=node . . 7 | RUN npm install 8 | RUN npx tsc 9 | 10 | # runtime 11 | FROM node:24-slim 12 | USER node 13 | 14 | WORKDIR /app 15 | COPY --chown=node package*.json ./ 16 | RUN npm ci --omit=dev && npm cache clean --force 17 | COPY --from=builder --chown=node /build/dist ./dist 18 | 19 | CMD ["node", "dist/index.js"] 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akahu-actual", 3 | "private": true, 4 | "peerDependencies": { 5 | "typescript": "^5" 6 | }, 7 | "dependencies": { 8 | "@actual-app/api": "^25.5.0", 9 | "akahu": "^2.1.0", 10 | "dotenv": "^17.0.0" 11 | }, 12 | "devDependencies": { 13 | "@types/jest": "^30.0.0", 14 | "@types/node": "^24.0.0", 15 | "jest": "^30.0.5", 16 | "jest-junit": "^16.0.0", 17 | "ts-jest": "^29.4.1", 18 | "ts-node": "^10.9.2" 19 | }, 20 | "scripts": { 21 | "test": "jest" 22 | }, 23 | "version": "0.9.0" 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "skipLibCheck": true, 5 | "target": "es2022", 6 | "module": "NodeNext", 7 | "allowJs": true, 8 | "moduleDetection": "force", 9 | "isolatedModules": true, 10 | "strict": true, 11 | "noUncheckedIndexedAccess": true, 12 | "noImplicitOverride": true, 13 | "outDir": "dist", 14 | "rootDir": "src", 15 | "sourceMap": true, 16 | "lib": [ 17 | "es2022", 18 | "dom" 19 | ] 20 | }, 21 | "include": [ 22 | "src" 23 | ], 24 | "exclude": [ 25 | "dist", 26 | "tests", 27 | "jest.config.js" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | actual_server: 3 | container_name: actual_server 4 | image: docker.io/actualbudget/actual-server:latest 5 | ports: 6 | - "5006:5006" 7 | volumes: 8 | - ./actual-data:/data 9 | restart: unless-stopped 10 | healthcheck: 11 | test: ["CMD-SHELL", "node src/scripts/health-check.js"] 12 | interval: 60s 13 | timeout: 10s 14 | retries: 3 15 | start_period: 20s 16 | 17 | akahu-actual: 18 | container_name: akahu-actual 19 | build: . 20 | env_file: 21 | - .env 22 | depends_on: 23 | actual_server: 24 | condition: service_healthy 25 | -------------------------------------------------------------------------------- /src/utils/transaction-transformer.ts: -------------------------------------------------------------------------------- 1 | import type { EnrichedTransaction } from "akahu"; 2 | import type { ActualTransaction } from "../services/actual-service.ts"; 3 | 4 | export function transformTransaction( 5 | transaction: EnrichedTransaction, 6 | ): ActualTransaction { 7 | return { 8 | imported_id: transaction._id, 9 | date: new Date(transaction.date), 10 | amount: Math.round(transaction.amount * 100), 11 | payee_name: transaction.merchant?.name || transaction.description, 12 | notes: formatTransactionNotes(transaction), 13 | }; 14 | } 15 | 16 | function formatTransactionNotes(transaction: EnrichedTransaction): string { 17 | return `${transaction.type} • ${transaction.category?.name || ""} • ${transaction.description || ""}` 18 | .replace(/\s+•\s+$/, "") 19 | .replace(/\s+•\s+•\s+/, " • "); 20 | } 21 | -------------------------------------------------------------------------------- /tests/akahu-service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from "@jest/globals"; 2 | import { AkahuService } from "../src/services/akahu-service"; 3 | 4 | describe("AkahuService", () => { 5 | const appToken = "app_token"; 6 | const userToken = "user_token"; 7 | let service: AkahuService; 8 | 9 | beforeEach(() => { 10 | service = new AkahuService(appToken, userToken); 11 | }); 12 | 13 | it("can be instantiated", () => { 14 | expect(service).toBeInstanceOf(AkahuService); 15 | }); 16 | 17 | it("getTransactions can be called (mocked)", async () => { 18 | service.getTransactions = jest 19 | .fn<() => Promise>() 20 | .mockResolvedValue([]); 21 | await expect( 22 | service.getTransactions({ 23 | start: "2025-08-01", 24 | end: "2025-08-04", 25 | } as any), 26 | ).resolves.toEqual([]); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Scott McKendry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/actual-service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { ActualService } from '../src/services/actual-service'; 3 | 4 | describe('ActualService', () => { 5 | const serverURL = 'http://localhost'; 6 | const password = 'password'; 7 | const e2eEncryptionPassword = 'e2e'; 8 | const syncId = 'sync_id'; 9 | let service: ActualService; 10 | 11 | beforeEach(() => { 12 | service = new ActualService(serverURL, password, e2eEncryptionPassword, syncId); 13 | }); 14 | 15 | it('can be instantiated', () => { 16 | expect(service).toBeInstanceOf(ActualService); 17 | }); 18 | 19 | it('initialize, importTransactions, and shutdown can be called (mocked)', async () => { 20 | service.initialize = jest.fn<() => Promise>().mockResolvedValue(undefined); 21 | service.importTransactions = jest.fn<(accountId: string, transactions: any[]) => Promise>().mockResolvedValue(undefined); 22 | service.shutdown = jest.fn<() => Promise>().mockResolvedValue(undefined); 23 | await expect(service.initialize()).resolves.toBeUndefined(); 24 | await expect(service.importTransactions('acc', [])).resolves.toBeUndefined(); 25 | await expect(service.shutdown()).resolves.toBeUndefined(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/transaction-transformer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "@jest/globals"; 2 | import { transformTransaction } from "../src/utils/transaction-transformer"; 3 | 4 | describe("transformTransaction", () => { 5 | it("transforms an enriched transaction to actual transaction", () => { 6 | const enriched = { 7 | _id: "tx1", 8 | date: "2025-08-01", 9 | amount: 12.34, 10 | merchant: { name: "Coffee Shop" }, 11 | description: "Latte", 12 | type: "debit", 13 | category: { name: "Food" }, 14 | }; 15 | const actual = transformTransaction(enriched as any); 16 | expect(actual.imported_id).toBe("tx1"); 17 | expect(actual.date).toEqual(new Date("2025-08-01")); 18 | expect(actual.amount).toBe(1234); 19 | expect(actual.payee_name).toBe("Coffee Shop"); 20 | expect(actual.notes).toMatch(/debit/); 21 | }); 22 | 23 | it("falls back to description if merchant missing", () => { 24 | const enriched = { 25 | _id: "tx2", 26 | date: "2025-08-01", 27 | amount: 10, 28 | description: "Groceries", 29 | type: "debit", 30 | category: { name: "Food" }, 31 | }; 32 | const actual = transformTransaction(enriched as any); 33 | expect(actual.payee_name).toBe("Groceries"); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/env-validator.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from "@jest/globals"; 2 | import { validateEnv } from "../src/utils/env-validator"; 3 | 4 | describe("validateEnv", () => { 5 | beforeEach(() => { 6 | process.env.AKAHU_APP_TOKEN = "app_token"; 7 | process.env.AKAHU_USER_TOKEN = "user_token"; 8 | process.env.ACTUAL_SERVER_URL = "http://localhost"; 9 | process.env.ACTUAL_PASSWORD = "password"; 10 | process.env.ACTUAL_SYNC_ID = "sync_id"; 11 | process.env.ACCOUNT_MAPPINGS = JSON.stringify({ foo: "bar" }); 12 | process.env.DAYS_TO_FETCH = "7"; 13 | }); 14 | 15 | it("returns validated config when env is valid", () => { 16 | const config = validateEnv(); 17 | expect(config.akahuAppToken).toBe("app_token"); 18 | expect(config.accountMappings).toEqual({ foo: "bar" }); 19 | expect(config.daysToFetch).toBe(7); 20 | }); 21 | 22 | it("throws if required env var is missing", () => { 23 | delete process.env.AKAHU_APP_TOKEN; 24 | expect(() => validateEnv()).toThrow(/AKAHU_APP_TOKEN is not set/); 25 | }); 26 | 27 | it("throws if ACCOUNT_MAPPINGS is empty", () => { 28 | process.env.ACCOUNT_MAPPINGS = JSON.stringify({}); 29 | expect(() => validateEnv()).toThrow(/ACCOUNT_MAPPINGS is empty/); 30 | }); 31 | 32 | it("throws if DAYS_TO_FETCH is invalid", () => { 33 | process.env.DAYS_TO_FETCH = "not_a_number"; 34 | expect(() => validateEnv()).toThrow( 35 | /DAYS_TO_FETCH must be a positive number/, 36 | ); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/utils/env-validator.ts: -------------------------------------------------------------------------------- 1 | interface EnvConfig { 2 | AKAHU_APP_TOKEN: string; 3 | AKAHU_USER_TOKEN: string; 4 | ACTUAL_SERVER_URL: string; 5 | ACTUAL_PASSWORD: string; 6 | ACTUAL_E2E_ENCRYPTION_PASSWORD: string; 7 | ACTUAL_SYNC_ID: string; 8 | ACCOUNT_MAPPINGS: string; 9 | RECONCILE_ACCOUNT_IDS?: string; 10 | DAYS_TO_FETCH?: string; 11 | } 12 | 13 | export interface ValidatedConfig { 14 | akahuAppToken: string; 15 | akahuUserToken: string; 16 | actualServerUrl: string; 17 | actualPassword: string; 18 | actualE2eEncryptionPassword: string | undefined; 19 | actualSyncId: string; 20 | accountMappings: Record; 21 | reconcileAccountIds: string[]; 22 | daysToFetch: number; 23 | } 24 | 25 | export function validateEnv(): ValidatedConfig { 26 | const requiredEnvVars: (keyof EnvConfig)[] = [ 27 | "AKAHU_APP_TOKEN", 28 | "AKAHU_USER_TOKEN", 29 | "ACTUAL_SERVER_URL", 30 | "ACTUAL_PASSWORD", 31 | "ACTUAL_SYNC_ID", 32 | "ACCOUNT_MAPPINGS", 33 | ]; 34 | 35 | for (const envVar of requiredEnvVars) { 36 | if (!process.env[envVar]) { 37 | throw new Error(`${envVar} is not set`); 38 | } 39 | } 40 | 41 | const accountMappings = JSON.parse(process.env.ACCOUNT_MAPPINGS!); 42 | if (Object.keys(accountMappings).length === 0) { 43 | throw new Error("ACCOUNT_MAPPINGS is empty"); 44 | } 45 | 46 | let reconcileAccountIds: string[] = []; 47 | reconcileAccountIds = JSON.parse(process.env.RECONCILE_ACCOUNT_IDS || "[]"); 48 | 49 | const daysToFetch = Number(process.env.DAYS_TO_FETCH || "7"); 50 | if (isNaN(daysToFetch) || daysToFetch <= 0) { 51 | throw new Error("DAYS_TO_FETCH must be a positive number"); 52 | } 53 | 54 | return { 55 | akahuAppToken: process.env.AKAHU_APP_TOKEN!, 56 | akahuUserToken: process.env.AKAHU_USER_TOKEN!, 57 | actualServerUrl: process.env.ACTUAL_SERVER_URL!, 58 | actualPassword: process.env.ACTUAL_PASSWORD!, 59 | actualE2eEncryptionPassword: process.env.ACTUAL_E2E_ENCRYPTION_PASSWORD, 60 | actualSyncId: process.env.ACTUAL_SYNC_ID!, 61 | accountMappings, 62 | daysToFetch, 63 | reconcileAccountIds, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | branches: 7 | - "*" 8 | 9 | name: ci 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 16 | - name: Set up Node.js 17 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 18 | with: 19 | node-version: "24" 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Run tests 23 | run: npm test 24 | - name: Test summary 25 | uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2 26 | with: 27 | paths: test-results/results.xml 28 | if: always() 29 | 30 | release-please: 31 | name: Release 32 | runs-on: ubuntu-latest 33 | needs: test 34 | outputs: 35 | release_created: ${{ steps.release.outputs.release_created }} 36 | tag_name: ${{ steps.release.outputs.tag_name }} 37 | steps: 38 | - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4 39 | id: release 40 | with: 41 | release-type: node 42 | 43 | docker: 44 | name: Publish Docker image 45 | needs: release-please 46 | if: ${{ needs.release-please.outputs.release_created == 'true' }} 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 50 | 51 | - name: Log in to GitHub Container Registry 52 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 53 | with: 54 | registry: ghcr.io 55 | username: ${{ github.actor }} 56 | password: ${{ secrets.GITHUB_TOKEN }} 57 | 58 | - name: Set up Docker Buildx 59 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 60 | with: 61 | use: true 62 | 63 | - name: Build and push Docker image 64 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 65 | with: 66 | context: . 67 | push: true 68 | tags: | 69 | ghcr.io/${{ github.repository }}:latest 70 | ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.tag_name }} 71 | provenance: true 72 | -------------------------------------------------------------------------------- /.renovaterc.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: [ 4 | "config:recommended", 5 | "docker:enableMajor", 6 | "helpers:pinGitHubActionDigests", 7 | ":dependencyDashboard", 8 | ":disableRateLimiting", 9 | ":semanticCommits", 10 | ], 11 | dependencyDashboard: true, 12 | dependencyDashboardTitle: "Renovate Dashboard 🤖", 13 | suppressNotifications: [ 14 | "prEditedNotification", 15 | "prIgnoreNotification", 16 | ], 17 | timezone: "Pacific/Auckland", 18 | packageRules: [ 19 | { 20 | matchUpdateTypes: ["major"], 21 | semanticCommitType: "feat", 22 | commitMessagePrefix: "{{semanticCommitType}}({{semanticCommitScope}})!:", 23 | commitMessageExtra: "( {{currentVersion}} → {{newVersion}} )", 24 | }, 25 | { 26 | matchUpdateTypes: ["minor"], 27 | semanticCommitType: "feat", 28 | commitMessageExtra: "( {{currentVersion}} → {{newVersion}} )", 29 | }, 30 | { 31 | matchUpdateTypes: ["patch"], 32 | semanticCommitType: "fix", 33 | commitMessageExtra: "( {{currentVersion}} → {{newVersion}} )", 34 | }, 35 | { 36 | matchUpdateTypes: ["digest"], 37 | semanticCommitType: "chore", 38 | commitMessageExtra: "( {{currentDigestShort}} → {{newDigestShort}} )", 39 | }, 40 | { 41 | matchDatasources: ["docker"], 42 | semanticCommitScope: "container", 43 | commitMessageTopic: "image {{depName}}", 44 | }, 45 | { 46 | matchManagers: ["github-actions"], 47 | semanticCommitType: "ci", 48 | semanticCommitScope: "github-action", 49 | commitMessageTopic: "action {{depName}}", 50 | }, 51 | { 52 | matchDatasources: ["github-releases"], 53 | semanticCommitScope: "github-release", 54 | commitMessageTopic: "release {{depName}}", 55 | }, 56 | { 57 | matchUpdateTypes: ["major"], 58 | labels: ["type/major"], 59 | }, 60 | { 61 | matchUpdateTypes: ["minor"], 62 | labels: ["type/minor"], 63 | }, 64 | { 65 | matchUpdateTypes: ["patch"], 66 | labels: ["type/patch"], 67 | }, 68 | { 69 | matchManagers: ["github-actions"], 70 | addLabels: ["renovate/github-action"], 71 | }, 72 | { 73 | matchDatasources: ["github-releases"], 74 | addLabels: ["renovate/github-release"], 75 | } 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /src/services/akahu-service.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Account, 3 | TransactionQueryParams, 4 | EnrichedTransaction, 5 | } from "akahu"; 6 | 7 | export class AkahuService { 8 | private readonly baseURL = "https://api.akahu.io/v1"; 9 | 10 | constructor( 11 | private readonly appToken: string, 12 | private readonly userToken: string, 13 | ) {} 14 | 15 | private getHeaders(): HeadersInit { 16 | return { 17 | "Content-Type": "application/json", 18 | Accept: "application/json", 19 | Authorization: `Bearer ${this.userToken}`, 20 | "X-Akahu-Id": this.appToken, 21 | }; 22 | } 23 | 24 | async getAccounts(): Promise { 25 | const response = await fetch(`${this.baseURL}/accounts`, { 26 | method: "GET", 27 | headers: this.getHeaders(), 28 | }); 29 | 30 | if (!response.ok) { 31 | throw new Error(`HTTP error! status: ${response.status}`); 32 | } 33 | 34 | const data = (await response.json()) as { items: Account[] }; 35 | return data.items; 36 | } 37 | 38 | async getTransactions( 39 | query: TransactionQueryParams, 40 | ): Promise { 41 | const transactions: EnrichedTransaction[] = []; 42 | let currentCursor: string | null = null; 43 | 44 | do { 45 | const queryParams = new URLSearchParams({ 46 | start: query.start!, 47 | end: query.end!, 48 | ...(currentCursor ? { cursor: currentCursor } : {}), 49 | }); 50 | 51 | const response = await fetch( 52 | `${this.baseURL}/transactions?${queryParams}`, 53 | { 54 | method: "GET", 55 | headers: this.getHeaders(), 56 | }, 57 | ); 58 | 59 | if (!response.ok) { 60 | throw new Error(`HTTP error! status: ${response.status}`); 61 | } 62 | 63 | const data = (await response.json()) as { 64 | items: EnrichedTransaction[]; 65 | cursor: { next: string | null }; 66 | }; 67 | 68 | transactions.push(...data.items); 69 | currentCursor = data.cursor.next; 70 | } while (currentCursor); 71 | 72 | return transactions; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/services/actual-service.ts: -------------------------------------------------------------------------------- 1 | import * as api from "@actual-app/api"; 2 | import { ImportTransactionEntity } from "@actual-app/api/@types/loot-core/src/types/models"; 3 | import * as os from "os"; 4 | 5 | export interface ActualTransaction { 6 | imported_id: string; 7 | date: Date; 8 | amount: number; 9 | payee_name: string; 10 | notes: string; 11 | } 12 | 13 | export class ActualService { 14 | constructor( 15 | private readonly serverURL: string, 16 | private readonly password: string, 17 | private readonly e2eEncryptionPassword: string | undefined, 18 | private readonly syncId: string, 19 | ) {} 20 | 21 | async initialize(): Promise { 22 | await api.init({ 23 | dataDir: os.tmpdir(), 24 | serverURL: this.serverURL, 25 | password: this.password, 26 | }); 27 | const downloadParams = this.e2eEncryptionPassword 28 | ? { password: this.e2eEncryptionPassword } 29 | : undefined; 30 | await api.downloadBudget(this.syncId, downloadParams); 31 | } 32 | 33 | async importTransactions( 34 | accountId: string, 35 | transactions: ActualTransaction[], 36 | ): Promise { 37 | const formattedTransactions: ImportTransactionEntity[] = 38 | transactions.map((t) => { 39 | const dateStr = t.date.toISOString().slice(0, 10); 40 | return { 41 | account: accountId, 42 | date: dateStr, 43 | amount: t.amount, 44 | payee_name: t.payee_name, 45 | notes: t.notes, 46 | imported_id: t.imported_id, 47 | }; 48 | }); 49 | await api.importTransactions(accountId, formattedTransactions); 50 | } 51 | 52 | async reconcileAccountBalance( 53 | accountId: string, 54 | targetBalanceDecimal: number, 55 | ): Promise { 56 | const targetBalance = Math.round(targetBalanceDecimal * 100); 57 | const currentBalance = await api.getAccountBalance(accountId); 58 | const delta = targetBalance - currentBalance; 59 | if (delta === 0) { 60 | return; 61 | } 62 | const adjustment: ActualTransaction = { 63 | imported_id: `balance_adjustment_${accountId}_${new Date() 64 | .toISOString() 65 | .slice(0, 10)}`, 66 | date: new Date(), 67 | amount: delta, 68 | payee_name: "Balance Adjustment", 69 | notes: "Auto-reconcile from Akahu balance", 70 | }; 71 | await this.importTransactions(accountId, [adjustment]); 72 | } 73 | 74 | async shutdown(): Promise { 75 | await api.shutdown(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | tmp/My-Finances-e136d51 138 | actual-data 139 | test-results 140 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TransactionQueryParams, 3 | EnrichedTransaction, 4 | Account, 5 | } from "akahu"; 6 | import { config } from "dotenv"; 7 | import { AkahuService } from "./services/akahu-service.js"; 8 | import { ActualService } from "./services/actual-service.js"; 9 | import { transformTransaction } from "./utils/transaction-transformer.js"; 10 | import { validateEnv, ValidatedConfig } from "./utils/env-validator.js"; 11 | 12 | async function main() { 13 | try { 14 | config(); 15 | const conf: ValidatedConfig = validateEnv(); 16 | const query: TransactionQueryParams = { 17 | start: new Date( 18 | Date.now() - conf.daysToFetch * 86400 * 1000, 19 | ).toISOString(), 20 | end: new Date(Date.now()).toISOString(), 21 | }; 22 | 23 | // Initialize services 24 | const akahuService = new AkahuService( 25 | conf.akahuAppToken, 26 | conf.akahuUserToken, 27 | ); 28 | 29 | const actualService = new ActualService( 30 | conf.actualServerUrl, 31 | conf.actualPassword, 32 | conf.actualE2eEncryptionPassword, 33 | conf.actualSyncId, 34 | ); 35 | 36 | // Fetch transactions 37 | const transactions = await akahuService.getTransactions(query); 38 | 39 | // Import transactions to Actual 40 | await actualService.initialize(); 41 | 42 | for (const [akahuId, actualId] of Object.entries( 43 | conf.accountMappings, 44 | )) { 45 | const accountTransactions = transactions 46 | .filter((t: EnrichedTransaction) => t._account === akahuId) 47 | .map(transformTransaction); 48 | 49 | await actualService.importTransactions( 50 | actualId, 51 | accountTransactions, 52 | ); 53 | } 54 | 55 | // Reconcile balances for specified Akahu accounts 56 | if (conf.reconcileAccountIds.length > 0) { 57 | const akahuAccounts: Account[] = await akahuService.getAccounts(); 58 | const balanceByAkahuId = new Map(); 59 | for (const acct of akahuAccounts) { 60 | if (acct.balance == undefined) { 61 | continue; 62 | } 63 | const balance = acct.balance.current; 64 | balanceByAkahuId.set(acct._id, balance); 65 | } 66 | for (const akahuId of conf.reconcileAccountIds) { 67 | const actualId = conf.accountMappings[akahuId]; 68 | if (!actualId) { 69 | console.warn( 70 | `No Actual account mapping for Akahu account ID: ${akahuId}`, 71 | ); 72 | continue; 73 | } 74 | const targetBalance = balanceByAkahuId.get(akahuId); 75 | if (typeof targetBalance !== "number") { 76 | console.warn( 77 | `No balance found for Akahu account ID: ${akahuId}`, 78 | ); 79 | continue; 80 | } 81 | await actualService.reconcileAccountBalance( 82 | actualId, 83 | targetBalance, 84 | ); 85 | } 86 | } 87 | 88 | await actualService.shutdown(); 89 | } catch (error) { 90 | console.error("Error:", error); 91 | process.exit(1); 92 | } 93 | } 94 | 95 | main(); 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Akahu to Actual Budget 🚀 2 | 3 | A dead-simple script that connects the brilliant [Akahu API](https://akahu.nz/) to the equally brilliant [Actual Budget](https://actualbudget.org/) app. 4 | 5 | An ideal solution for Kiwis who want to use Actual Budget to manage their finances, but find the manual import process a bit tedious. 6 | 7 | ## 🔑 Prerequisites 8 | 9 | - An Akahu account with a configured personal app ([see Akuhu's documentation](https://developers.akahu.nz/docs/personal-apps)) 10 | - Docker (recommended) or Node.js 11 | 12 | > [!NOTE] 13 | > Only the **Transactions** permission is required in your Akahu app settings. 14 | 15 | ## ⚡ How it Works 16 | 17 | 1. Connects to the Akahu API using your personal app and user tokens 18 | 2. Pulls transactions from all connected accounts, mapping them to their corresponding Actual Budget accounts 19 | 3. Transforms the transactions into a format that Actual Budget can understand 20 | 4. Pushes the transactions to Actual Budget using its API 21 | 22 | ## 🛠️ Configuration 23 | 24 | Create an `.env` file with the following settings: 25 | 26 | ```bash 27 | # Akahu API Credentials 28 | AKAHU_APP_TOKEN=app_token_abcd 29 | AKAHU_USER_TOKEN=user_token_1234 30 | 31 | # Account Mappings 32 | # Format: {"akahu_account_id": "actual_account_id"} 33 | # - Akahu IDs: Found in URL when viewing account on my.akahu.nz (format: acc_xxx...) 34 | # - Actual IDs: Found in URL when viewing account in Actual Budget (GUID format) 35 | ACCOUNT_MAPPINGS={"akahu_account_id_1":"actual_account_id_1", "akahu_account_id_2":"actual_account_id_2"} 36 | 37 | # Actual Budget Configuration 38 | ACTUAL_SERVER_URL=http://localhost:5006 # URL of your Actual server 39 | ACTUAL_PASSWORD=password # Your Actual master password 40 | ACTUAL_SYNC_ID=00000000-0000-0000-0000-000000000000 # Found in Settings -> Advanced Settings 41 | 42 | # Optional Settings 43 | DAYS_TO_FETCH=7 # Number of days of transaction history to fetch (default: 7) 44 | ACTUAL_E2E_ENCRYPTION_PASSWORD=password # Actual E2E encryption password, if enabled (default: undefined) 45 | RECONCILE_ACCOUNT_IDS=["akahu_account_id_1","akahu_account_id_2"] # JSON array of Akahu account IDs to reconcile after import (default: []) 46 | ``` 47 | 48 | > [!TIP] 49 | > Certain accounts may be influenced by factors other than transactions (e.g. investments, KiwiSaver). In which case, relying solely on transaction imports may lead to discrepancies. Use the `RECONCILE_ACCOUNT_IDS` setting to specify which accounts should be reconciled after import, ensuring their balances align with their real-world counterparts. 50 | 51 | ## 🚀 Deployment Options 52 | 53 | ### Option 1: Docker Compose (Recommended) 54 | 55 | Create a `docker-compose.yml` file: 56 | 57 | ```yaml 58 | services: 59 | actual_server: 60 | container_name: actual_server 61 | image: docker.io/actualbudget/actual-server:latest 62 | ports: 63 | - "5006:5006" 64 | volumes: 65 | - ./actual-data:/data 66 | restart: unless-stopped 67 | healthcheck: 68 | test: ["CMD-SHELL", "node src/scripts/health-check.js"] 69 | interval: 60s 70 | timeout: 10s 71 | retries: 3 72 | start_period: 20s 73 | 74 | akahu-actual: 75 | container_name: akahu-actual 76 | image: ghcr.io/scottmckendry/akahu-actual 77 | env_file: 78 | - .env 79 | depends_on: 80 | actual_server: 81 | condition: service_healthy 82 | ``` 83 | 84 | > [!TIP] 85 | > The `akahu-actual` container runs once and exits. To run it again: 86 | > 87 | > ```bash 88 | > docker restart akahu-actual 89 | > ``` 90 | > 91 | > Consider setting up a cron job for automatic scheduling. 92 | 93 | > [!NOTE] 94 | > If you're deploying to Kubernetes, I suggest taking a look at the example in my homelab repo [here](https://github.com/scottmckendry/axis/blob/main/kubernetes/actual/akahu-actual/release.yaml). 95 | 96 | ### Option 2: Local Node.js Installation 97 | 98 | 1. Clone the repository 99 | 2. Install dependencies: 100 | 101 | ```bash 102 | npm install 103 | ``` 104 | 105 | 3. Run the script: 106 | 107 | ```bash 108 | npx tsc && node dist/index.js 109 | ``` 110 | 111 | ## 🤝 Contributing 112 | 113 | Contributions are welcome! Please feel free to submit a Pull Request. 114 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.9.0](https://github.com/scottmckendry/akahu-actual/compare/v0.8.0...v0.9.0) (2025-12-07) 4 | 5 | 6 | ### Features 7 | 8 | * add optional auto-reconciliation ([#80](https://github.com/scottmckendry/akahu-actual/issues/80)) ([0e66405](https://github.com/scottmckendry/akahu-actual/commit/0e6640505d0b06728f4c6bc725405ddc5351bfb7)) 9 | * **deps:** update dependency @actual-app/api ( 25.11.0 → 25.12.0 ) ([d8db16c](https://github.com/scottmckendry/akahu-actual/commit/d8db16cac61a5c75426cef3613630a9f12b2f040)) 10 | 11 | 12 | ### Bug Fixes 13 | 14 | * **deps:** update dependency ts-jest ( 29.4.5 → 29.4.6 ) ([29c873f](https://github.com/scottmckendry/akahu-actual/commit/29c873fe942a4326750995d3b387e4d0d2ffab17)) 15 | 16 | ## [0.8.0](https://github.com/scottmckendry/akahu-actual/compare/v0.7.1...v0.8.0) (2025-11-29) 17 | 18 | 19 | ### Features 20 | 21 | * **deps:** update dependency @actual-app/api ( 25.9.0 → 25.11.0 ) ([2d7ab3f](https://github.com/scottmckendry/akahu-actual/commit/2d7ab3ff8c7368ea348a9ca3a4c92267bc3b0642)) 22 | * **deps:** update dependency @types/node ( 22.18.3 → 22.19.1 ) ([537978f](https://github.com/scottmckendry/akahu-actual/commit/537978fd757c53867d55bac93c8639f1a881b5ee)) 23 | * **deps:** update dependency akahu ( 2.2.0 → 2.3.0 ) ([d3e4052](https://github.com/scottmckendry/akahu-actual/commit/d3e4052dffa428db69301a37a0171e16653314ed)) 24 | * **deps:** update dependency jest ( 30.1.3 → 30.2.0 ) ([5eca9c3](https://github.com/scottmckendry/akahu-actual/commit/5eca9c35d7cbf170af3610bd788286b68f8ba5cf)) 25 | * **github-release:** Update release node ( 22.21.1 → 24.11.1 ) ([21ad92e](https://github.com/scottmckendry/akahu-actual/commit/21ad92eca79f1a496205d5b732e2dca6ab5d210b)) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * **deps:** update dependency dotenv ( 17.2.2 → 17.2.3 ) ([186d4e2](https://github.com/scottmckendry/akahu-actual/commit/186d4e203d540aad17fb16f2546a20227353199d)) 31 | * **deps:** update dependency ts-jest ( 29.4.1 → 29.4.5 ) ([f10c559](https://github.com/scottmckendry/akahu-actual/commit/f10c559aa7bdd38ef2d5a524dd77d56c4b98778b)) 32 | 33 | ## [0.7.1](https://github.com/scottmckendry/akahu-actual/compare/v0.7.0...v0.7.1) (2025-09-14) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * **build:** typescript compile paths inc excl ([30e2e9c](https://github.com/scottmckendry/akahu-actual/commit/30e2e9c10e97215da425d81f05fb2b2b40532204)) 39 | * **deps:** update dependency @types/node ( 22.18.1 → 22.18.3 ) ([16e1893](https://github.com/scottmckendry/akahu-actual/commit/16e1893c1998b1aa960b067d23e8d362b76dd5e3)) 40 | 41 | ## [0.7.0](https://github.com/scottmckendry/akahu-actual/compare/v0.6.0...v0.7.0) (2025-09-07) 42 | 43 | 44 | ### Features 45 | 46 | * **deps:** update dependency @actual-app/api ( 25.8.0 → 25.9.0 ) ([1fe66b9](https://github.com/scottmckendry/akahu-actual/commit/1fe66b979ea464db70c3563298ebaad57986420d)) 47 | * **deps:** update dependency @types/node ( 22.17.0 → 22.18.1 ) ([d739273](https://github.com/scottmckendry/akahu-actual/commit/d739273b1a619fabdb4f2020aa6dd0d24d86faa4)) 48 | * **deps:** update dependency jest ( 30.0.5 → 30.1.3 ) ([3c2f6d8](https://github.com/scottmckendry/akahu-actual/commit/3c2f6d81b9faf4f4e04488968d36f7b524ca78f5)) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * **deps:** update dependency dotenv ( 17.2.1 → 17.2.2 ) ([b12f7fb](https://github.com/scottmckendry/akahu-actual/commit/b12f7fb54b29591336a43e409a3b874333b650de)) 54 | 55 | ## [0.6.0](https://github.com/scottmckendry/akahu-actual/compare/v0.5.0...v0.6.0) (2025-08-04) 56 | 57 | 58 | ### Features 59 | 60 | * **deps:** update dependency @actual-app/api ( 25.7.1 → 25.8.0 ) ([fd2ea49](https://github.com/scottmckendry/akahu-actual/commit/fd2ea49dd32cbf1fcc1e55a561f71fc74dbb36de)) 61 | * **deps:** update dependency @types/node ( 22.16.0 → 22.17.0 ) ([ba54d9c](https://github.com/scottmckendry/akahu-actual/commit/ba54d9cd6ce01f186310eae722de6a60d26df3e5)) 62 | * **deps:** update dependency dotenv ( 17.0.1 → 17.2.1 ) ([6e874ac](https://github.com/scottmckendry/akahu-actual/commit/6e874ac8d223d5833f112a0057427a90e36bebdb)) 63 | * **tests:** publish results ([9b462cd](https://github.com/scottmckendry/akahu-actual/commit/9b462cd957017eea496672e038f469831ac0b36f)) 64 | * **tests:** setup test suite and add basic tests ([6c49af2](https://github.com/scottmckendry/akahu-actual/commit/6c49af2da7063e43e0ca7fdae4cafb5a6b14a0e1)) 65 | 66 | ## [0.5.0](https://github.com/scottmckendry/akahu-actual/compare/v0.4.0...v0.5.0) (2025-07-03) 67 | 68 | 69 | ### Features 70 | 71 | * **deps:** update dependency @types/node ( 22.15.34 → 22.16.0 ) ([cc06130](https://github.com/scottmckendry/akahu-actual/commit/cc061305e0f67c86b45e31e7bcc956f1dd60efb2)) 72 | * **deps:** update dependency dotenv ( 16.5.0 → 16.6.0 ) ([0bca0d7](https://github.com/scottmckendry/akahu-actual/commit/0bca0d77b543cbd4ce9aa9103e5e284ae3ce6f3a)) 73 | * **deps:** Update dependency dotenv ( 16.6.0 → 17.0.0 ) ([#36](https://github.com/scottmckendry/akahu-actual/issues/36)) ([77c9e5d](https://github.com/scottmckendry/akahu-actual/commit/77c9e5dc6d74265ee66d45367bb06fdbfbcf3b03)) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * **actual:** fix type error in actual service ([a0c7a0d](https://github.com/scottmckendry/akahu-actual/commit/a0c7a0d9bfcae9719cbec280f00d6e5e29f528ae)) 79 | * **deps:** update dependency @actual-app/api ( 25.7.0 → 25.7.1 ) ([67b4748](https://github.com/scottmckendry/akahu-actual/commit/67b4748175f77df428fbb3d0256bd8a2d6f24d5d)) 80 | * **deps:** update dependency @types/node ( 22.15.33 → 22.15.34 ) ([e8109ec](https://github.com/scottmckendry/akahu-actual/commit/e8109ecb03cd4f6892d497f889827c7fe4b4a745)) 81 | * **deps:** update dependency dotenv ( 17.0.0 → 17.0.1 ) ([0aafd1a](https://github.com/scottmckendry/akahu-actual/commit/0aafd1abbeab3e95a525b2ff5f12d7de3a9987ec)) 82 | 83 | ## [0.4.0](https://github.com/scottmckendry/akahu-actual/compare/v0.3.0...v0.4.0) (2025-06-26) 84 | 85 | 86 | ### Features 87 | 88 | * **deps:** update dependency akahu ( 2.1.0 → 2.2.0 ) ([bc4f6cd](https://github.com/scottmckendry/akahu-actual/commit/bc4f6cd073976d24f353243162094bff8ffb4fe7)) 89 | 90 | 91 | ### Bug Fixes 92 | 93 | * **deps:** update dependency @types/node ( 22.15.31 → 22.15.32 ) ([1e18c22](https://github.com/scottmckendry/akahu-actual/commit/1e18c22fc71d30b75877b9cf5bd6255cd0aae539)) 94 | * **deps:** update dependency @types/node ( 22.15.32 → 22.15.33 ) ([9d42b7a](https://github.com/scottmckendry/akahu-actual/commit/9d42b7a990505cab36bdca11862bd320cb0824d3)) 95 | 96 | ## [0.3.0](https://github.com/scottmckendry/akahu-actual/compare/v0.2.0...v0.3.0) (2025-06-10) 97 | 98 | 99 | ### Features 100 | 101 | * **deps:** update dependency @actual-app/api ( 25.5.0 → 25.6.1 ) ([1c11349](https://github.com/scottmckendry/akahu-actual/commit/1c113491eb9ac4bab40781b03056a349bb3174eb)) 102 | 103 | 104 | ### Bug Fixes 105 | 106 | * **deps:** update dependency @types/node ( 22.15.29 → 22.15.31 ) ([7db5bd0](https://github.com/scottmckendry/akahu-actual/commit/7db5bd0d17f01365f329aa99bac0a35e4bd4e0ae)) 107 | 108 | ## [0.2.0](https://github.com/scottmckendry/akahu-actual/compare/v0.1.2...v0.2.0) (2025-05-24) 109 | 110 | 111 | ### Features 112 | 113 | * **actual:** support e2e encrypted instances ([a21f1e1](https://github.com/scottmckendry/akahu-actual/commit/a21f1e197e052119811456c8a5d03c56495bafa2)) 114 | 115 | ## [0.1.2](https://github.com/scottmckendry/akahu-actual/compare/v0.1.1...v0.1.2) (2025-05-08) 116 | 117 | 118 | ### Bug Fixes 119 | 120 | * use system tmp dir ([e7dcb6f](https://github.com/scottmckendry/akahu-actual/commit/e7dcb6f50093b0303cff640e2216814326098a44)) 121 | 122 | ## [0.1.1](https://github.com/scottmckendry/akahu-actual/compare/v0.1.0...v0.1.1) (2025-04-30) 123 | 124 | 125 | ### Bug Fixes 126 | 127 | * **docker:** network-error when using alpine as runtime image ([087878b](https://github.com/scottmckendry/akahu-actual/commit/087878be6c37a580d364ea5ecfd8eba61b357e14)) 128 | 129 | ## 0.1.0 (2025-04-30) 130 | 131 | 132 | ### Features 133 | 134 | * add dockerfile and example compose ([f3e05a4](https://github.com/scottmckendry/akahu-actual/commit/f3e05a4f5f7d984e48141a44e856dd1427f21ed1)) 135 | * **ci:** add release-please job ([7f0af79](https://github.com/scottmckendry/akahu-actual/commit/7f0af79ef08914c980edb19b900d68a60a9f2eee)) 136 | * **ci:** build and sign docker image ([2058cd1](https://github.com/scottmckendry/akahu-actual/commit/2058cd131daca7d8dfbf16c4b46813790128116e)) 137 | * improve config validation ([eced094](https://github.com/scottmckendry/akahu-actual/commit/eced094a08895ddf9fe445f94f3c64d3bfadbd11)) 138 | * initialize akahu to actual budget importer ([bebd364](https://github.com/scottmckendry/akahu-actual/commit/bebd36422b36391c6b3008233d0c7e5b0377ec62)) 139 | --------------------------------------------------------------------------------