aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-03-09 23:05:38 +0100
committerivar <i@oiee.no>2026-03-09 23:05:38 +0100
commit69448e29a85cad3a94b3be3ad33efbc52764528f (patch)
treec32b8c817322fdf26edbbb3fa75b9505a7020ae8
parentb35302fa020ec82a9d67a6cb34379d42983d3cfc (diff)
downloadsparebank1-actualbudget-69448e29a85cad3a94b3be3ad33efbc52764528f.tar.xz
sparebank1-actualbudget-69448e29a85cad3a94b3be3ad33efbc52764528f.zip
Add wip cliHEADmaster
-rw-r--r--app/src/lib/server/actual.ts87
-rw-r--r--app/src/routes/+page.server.ts6
-rw-r--r--app/src/routes/+page.svelte35
-rw-r--r--app/src/routes/methods.remote.ts14
-rw-r--r--cli/.gitignore34
-rw-r--r--cli/README.md15
-rw-r--r--cli/bun.lock200
-rw-r--r--cli/package.json22
-rw-r--r--cli/src/actual.ts51
-rw-r--r--cli/src/commands/accounts.ts46
-rw-r--r--cli/src/commands/auth.ts137
-rw-r--r--cli/src/commands/backup.ts75
-rw-r--r--cli/src/commands/import.ts45
-rw-r--r--cli/src/commands/init.ts76
-rw-r--r--cli/src/config.ts53
-rw-r--r--cli/src/index.ts39
-rw-r--r--cli/src/sb1.ts37
-rw-r--r--cli/src/tokens.ts63
-rw-r--r--cli/src/types.ts40
-rw-r--r--cli/tsconfig.json9
20 files changed, 1025 insertions, 59 deletions
diff --git a/app/src/lib/server/actual.ts b/app/src/lib/server/actual.ts
index fb1a4fb..ca4d9c4 100644
--- a/app/src/lib/server/actual.ts
+++ b/app/src/lib/server/actual.ts
@@ -1,5 +1,5 @@
import { ACTUAL_FILE_ID, ACTUAL_HOST, ACTUAL_PASS } from "$env/static/private";
-import * as actual from "@actual-app/api"
+import * as actualApi from "@actual-app/api"
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path"
import process from "node:process";
@@ -7,53 +7,66 @@ import type { ImportTransactionEntity } from "@actual-app/api/@types/loot-core/s
import { Temporal } from "temporal-polyfill";
import type { Sb1Transaction } from "$lib/shared";
-export async function init_actual() {
+let inited = false
+
+async function init() {
+ if (inited) return
const dataDir = path.resolve(process.cwd(), "data/actualDataDir")
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
- return actual.init({
+ await actualApi.init({
password: ACTUAL_PASS,
serverURL: ACTUAL_HOST,
- dataDir: dataDir
- }).then(async () => {
- await actual.downloadBudget(ACTUAL_FILE_ID)
- await actual.sync()
+ dataDir
})
+ await actualApi.downloadBudget(ACTUAL_FILE_ID)
+ await actualApi.sync()
+ inited = true
}
-export async function import_transactions(account: string, transactions: Sb1Transaction[], dryRun: boolean) {
- await init_actual()
- function parsedDate(date: number) {
- const instant = Temporal.Instant.fromEpochMilliseconds(date)
- return instant.toString({ timeZone: "Europe/Oslo" }).split("T")[0]
- }
+const budget = {
+ async get_budgets() {
+ await init()
+ return actualApi.getBudgets()
+ },
- function notes(transaction: Sb1Transaction) {
- const { description, cleanedDescription } = transaction
- if (description.toLowerCase().trim() === cleanedDescription.toLowerCase().trim()) return undefined
- return description
- }
+ async get_accounts() {
+ await init()
+ return actualApi.getAccounts()
+ },
- function amount(amount: number) {
- return Math.round(amount * 100)
- }
+ async import_transactions(account: string, transactions: Sb1Transaction[], dryRun: boolean) {
+ await init()
- const actualMappedTransactions: ImportTransactionEntity[] = transactions.filter(c => c.bookingStatus === "BOOKED").map(c => ({
- account,
- date: parsedDate(c.date),
- amount: amount(c.amount),
- notes: notes(c),
- payee_name: c.cleanedDescription
- }))
+ function parsedDate(date: number) {
+ return Temporal.Instant.fromEpochMilliseconds(date)
+ .toString({ timeZone: "Europe/Oslo" })
+ .split("T")[0]
+ }
- await actual.importTransactions(account, actualMappedTransactions, { dryRun })
-}
+ function notes(transaction: Sb1Transaction) {
+ const { description, cleanedDescription } = transaction
+ if (description?.toLowerCase().trim() === cleanedDescription?.toLowerCase().trim()) return undefined
+ return description
+ }
-export async function get_budgets() {
- await init_actual()
- return actual.getBudgets()
-}
+ function amount(amount: number) {
+ const res = Math.round(amount * 10000)
+ console.log(`${amount}->${res}`)
+ return res
+ }
+
+ const mapped: ImportTransactionEntity[] = transactions
+ .filter(c => c.bookingStatus === "BOOKED")
+ .map(c => ({
+ account,
+ date: parsedDate(c.date),
+ amount: amount(c.amount),
+ notes: notes(c),
+ payee_name: c.cleanedDescription
+ }))
-export async function get_accounts() {
- await init_actual()
- return actual.getAccounts()
+ return actualApi.importTransactions(account, mapped, { dryRun })
+ }
}
+
+export default { init, budget }
diff --git a/app/src/routes/+page.server.ts b/app/src/routes/+page.server.ts
index 5d3e857..df076d7 100644
--- a/app/src/routes/+page.server.ts
+++ b/app/src/routes/+page.server.ts
@@ -1,12 +1,12 @@
import type { PageServerLoad } from './$types';
-import { get_accounts, get_budgets } from '$lib/server/actual';
+import actual from '$lib/server/actual';
import sb1 from "$lib/server/sb1"
export const load = (async () => {
return {
actual: {
- budgets: await get_budgets(),
- accounts: await get_accounts(),
+ budgets: await actual.budget.get_budgets(),
+ accounts: await actual.budget.get_accounts(),
},
sb1: {
accounts: (await sb1.data.get_accounts())?.accounts
diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte
index b12c471..693a430 100644
--- a/app/src/routes/+page.svelte
+++ b/app/src/routes/+page.svelte
@@ -1,6 +1,10 @@
<script lang="ts">
import Button from "$lib/ui/button.svelte";
- import { clear_auth_session, init_auth_session, do_import } from "./methods.remote";
+ import {
+ clear_auth_session,
+ init_auth_session,
+ do_import,
+ } from "./methods.remote";
import type { PageProps } from "./$types";
import type { ImportForm } from "$lib/shared";
@@ -33,7 +37,8 @@
function onMappingChanged(sb1Id: string, actualId: string) {
let mappings = form.mappings;
- if (mappings.find((c) => c.sb1Id === sb1Id)) mappings = mappings.filter((c) => c.sb1Id !== sb1Id);
+ if (mappings.find((c) => c.sb1Id === sb1Id))
+ mappings = mappings.filter((c) => c.sb1Id !== sb1Id);
mappings.push({ sb1Id, actualId });
form.mappings = mappings;
}
@@ -44,12 +49,6 @@
<form onsubmit={run}>
<h3>Importer</h3>
<fieldset>
- <h4>Budsjett</h4>
- {#each data.actual.budgets as budget}
- {@const id = `budget-${budget.id}`}
- <input name="budget" {id} value={budget.id} type="radio" bind:group={form.budgetId} />
- <label for={id}>{budget.name}({budget.id})</label><br />
- {/each}
<h4>Kontoer</h4>
{#each data.sb1.accounts as account}
{@const actualId = `mapping-${account.key}-actual`}
@@ -57,7 +56,15 @@
<code>{account.name}</code>
<span>&#8594;</span>
<label for={actualId}>Actual</label>
- <select name={actualId} id={actualId} onchange={(e) => onMappingChanged(account.key, e.currentTarget.value)}>
+ <select
+ name={actualId}
+ id={actualId}
+ onchange={(e) =>
+ onMappingChanged(
+ account.key,
+ e.currentTarget.value,
+ )}
+ >
<option value="-" selected>-</option>
{#each data.actual.accounts as actual}
<option value={actual.id}>
@@ -68,7 +75,11 @@
</div>
{/each}
<h4>Ellers</h4>
- <input type="checkbox" id="dry" bind:checked={form.dryRun} /><label for="dry">Tørrkjøring</label><br /><br />
+ <input
+ type="checkbox"
+ id="dry"
+ bind:checked={form.dryRun}
+ /><label for="dry">Tørrkjøring</label><br /><br />
<input type="submit" />
</fieldset>
</form>
@@ -76,6 +87,8 @@
<Button onclick={logout} loading={navigating}>Logg ut</Button>
<div></div>
{:else}
- <Button onclick={authorize} loading={navigating}>Autentisér hos Sparebanken 1</Button>
+ <Button onclick={authorize} loading={navigating}
+ >Autentisér hos Sparebanken 1</Button
+ >
{/if}
</main>
diff --git a/app/src/routes/methods.remote.ts b/app/src/routes/methods.remote.ts
index d9ba812..d6fd908 100644
--- a/app/src/routes/methods.remote.ts
+++ b/app/src/routes/methods.remote.ts
@@ -2,7 +2,7 @@ import { db } from "$lib/server/db";
import { SyncSessionTable } from "$lib/server/db/schema";
import { command, query } from "$app/server";
import sb1 from "$lib/server/sb1";
-import { import_transactions, init_actual } from "$lib/server/actual";
+import actual from "$lib/server/actual";
import { ImportForm } from "$lib/shared";
const init_auth_session = command(async () => {
@@ -16,10 +16,8 @@ const clear_auth_session = query(async () => {
const do_import = command(ImportForm, async (form) => {
for (const mapping of form.mappings) {
const transactions = await sb1.data.get_transactions(mapping.sb1Id)
- console.log(transactions)
- continue
- // if (!transactions?.length) continue
- // console.log(await import_transactions(mapping.actualId, transactions, form.dryRun))
+ if (!transactions?.length) continue
+ console.log(await actual.budget.import_transactions(mapping.actualId, transactions, form.dryRun))
}
})
@@ -27,14 +25,14 @@ const init_sb1 = command(async () => {
return await sb1.init()
})
-const _init_actual = command(async () => {
- return await init_actual()
+const init_actual = command(async () => {
+ return await actual.init()
})
export {
init_auth_session,
do_import,
- _init_actual as init_actual,
+ init_actual,
init_sb1,
clear_auth_session
}
diff --git a/cli/.gitignore b/cli/.gitignore
new file mode 100644
index 0000000..a14702c
--- /dev/null
+++ b/cli/.gitignore
@@ -0,0 +1,34 @@
+# dependencies (bun install)
+node_modules
+
+# output
+out
+dist
+*.tgz
+
+# code coverage
+coverage
+*.lcov
+
+# logs
+logs
+_.log
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# caches
+.eslintcache
+.cache
+*.tsbuildinfo
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 0000000..cdf1245
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,15 @@
+# sb1-actual
+
+To install dependencies:
+
+```bash
+bun install
+```
+
+To run:
+
+```bash
+bun run src/index.ts
+```
+
+This project was created using `bun init` in bun v1.2.21. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
diff --git a/cli/bun.lock b/cli/bun.lock
new file mode 100644
index 0000000..18a1d0e
--- /dev/null
+++ b/cli/bun.lock
@@ -0,0 +1,200 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 0,
+ "workspaces": {
+ "": {
+ "name": "sb1-actual",
+ "dependencies": {
+ "@actual-app/api": "^26.3.0",
+ "@clack/prompts": "^1.1.0",
+ "temporal-polyfill": "^0.3.0",
+ },
+ "devDependencies": {
+ "@types/node": "^22",
+ "tsx": "^4",
+ "typescript": "^5",
+ },
+ },
+ },
+ "packages": {
+ "@actual-app/api": ["@actual-app/api@26.3.0", "", { "dependencies": { "@actual-app/crdt": "^2.1.0", "better-sqlite3": "^12.6.2", "compare-versions": "^6.1.1", "node-fetch": "^3.3.2", "uuid": "^13.0.0" } }, "sha512-03OG+udLh5GXG4I4AbfcRNhLT35vRfgHtE1JnkWGRwv6nFHY+KckPOmsDAX50fw7Q3vxPA8usHkG3JyGcRYSew=="],
+
+ "@actual-app/crdt": ["@actual-app/crdt@2.1.0", "", { "dependencies": { "google-protobuf": "^3.12.0-rc.1", "murmurhash": "^2.0.1", "uuid": "^9.0.0" } }, "sha512-Qb8hMq10Wi2kYIDj0fG4uy00f9Mloghd+xQrHQiPQfgx022VPJ/No+z/bmfj4MuFH8FrPiLysSzRsj2PNQIedw=="],
+
+ "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="],
+
+ "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
+
+ "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
+
+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
+ "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
+
+ "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
+
+ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
+
+ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
+
+ "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
+
+ "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="],
+
+ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
+
+ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
+
+ "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
+
+ "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+
+ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
+
+ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
+
+ "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
+
+ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
+
+ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
+
+ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
+
+ "google-protobuf": ["google-protobuf@3.21.4", "", {}, "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ=="],
+
+ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
+ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
+
+ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
+
+ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
+
+ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
+
+ "murmurhash": ["murmurhash@2.0.1", "", {}, "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew=="],
+
+ "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
+
+ "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="],
+
+ "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
+
+ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
+
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
+ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
+
+ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
+
+ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
+
+ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+
+ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
+ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
+
+ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
+
+ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
+
+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
+ "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
+
+ "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
+
+ "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
+
+ "temporal-polyfill": ["temporal-polyfill@0.3.0", "", { "dependencies": { "temporal-spec": "0.3.0" } }, "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g=="],
+
+ "temporal-spec": ["temporal-spec@0.3.0", "", {}, "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ=="],
+
+ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
+
+ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
+
+ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "@actual-app/crdt/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
+ }
+}
diff --git a/cli/package.json b/cli/package.json
new file mode 100644
index 0000000..bdaf9fd
--- /dev/null
+++ b/cli/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "sb1-actual",
+ "version": "0.1.0",
+ "type": "module",
+ "bin": {
+ "sb1-actual": "./src/index.ts"
+ },
+ "scripts": {
+ "start": "tsx src/index.ts"
+ },
+ "dependencies": {
+ "@actual-app/api": "^26.3.0",
+ "@clack/prompts": "^1.1.0",
+ "temporal-polyfill": "^0.3.0"
+ },
+ "private": true,
+ "devDependencies": {
+ "@types/node": "^22",
+ "tsx": "^4",
+ "typescript": "^5"
+ }
+}
diff --git a/cli/src/actual.ts b/cli/src/actual.ts
new file mode 100644
index 0000000..a236013
--- /dev/null
+++ b/cli/src/actual.ts
@@ -0,0 +1,51 @@
+import * as actualApi from "@actual-app/api"
+import { existsSync, mkdirSync } from "node:fs"
+import { join } from "node:path"
+import { Temporal } from "temporal-polyfill"
+import { CONFIG_DIR } from "./config"
+import type { Config } from "./config"
+import type { Sb1Transaction, ActualAccount } from "./types"
+import type { ImportTransactionEntity } from "@actual-app/api/@types/loot-core/src/types/models/import-transaction"
+
+let inited = false
+
+export async function initActual(config: Config["actual"]) {
+ if (inited) return
+ const dataDir = join(CONFIG_DIR, "actualDataDir")
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true })
+ process.env.ACTUAL_DATA_DIR = dataDir
+ await actualApi.init({ password: config.password, serverURL: config.host, dataDir })
+ await actualApi.downloadBudget(config.fileId)
+ await actualApi.sync()
+ inited = true
+}
+
+export async function getAccounts(config: Config["actual"]): Promise<ActualAccount[]> {
+ await initActual(config)
+ return actualApi.getAccounts() as Promise<ActualAccount[]>
+}
+
+export async function importTransactions(
+ config: Config["actual"],
+ accountId: string,
+ transactions: Sb1Transaction[],
+ dryRun: boolean
+) {
+ await initActual(config)
+
+ const mapped: ImportTransactionEntity[] = transactions
+ .filter(t => t.bookingStatus === "BOOKED")
+ .map(t => ({
+ account: accountId,
+ date: Temporal.Instant.fromEpochMilliseconds(t.date)
+ .toString({ timeZone: "Europe/Oslo" })
+ .split("T")[0],
+ amount: Math.round(t.amount * 100),
+ payee_name: t.cleanedDescription,
+ notes: t.description?.toLowerCase().trim() !== t.cleanedDescription?.toLowerCase().trim()
+ ? t.description
+ : undefined
+ }))
+
+ return actualApi.importTransactions(accountId, mapped, { dryRun })
+}
diff --git a/cli/src/commands/accounts.ts b/cli/src/commands/accounts.ts
new file mode 100644
index 0000000..4b7e980
--- /dev/null
+++ b/cli/src/commands/accounts.ts
@@ -0,0 +1,46 @@
+import * as p from "@clack/prompts"
+import { loadConfig, saveConfig } from "../config"
+import { createSb1Client } from "../sb1"
+import { getAccounts } from "../actual"
+
+export async function accounts() {
+ const config = loadConfig()
+ const sb1 = createSb1Client(config.sb1)
+
+ const spinner = p.spinner()
+ spinner.start("Fetching accounts...")
+ const [sb1Accounts, actualAccounts] = await Promise.all([
+ sb1.getAccounts(),
+ getAccounts(config.actual)
+ ])
+ spinner.stop("Accounts loaded.")
+
+ const openActualAccounts = actualAccounts.filter(a => !a.closed)
+
+ p.intro("Account mappings")
+
+ const mappings = []
+ for (const sb1Account of sb1Accounts) {
+ const existing = config.mappings.find(m => m.sb1Id === sb1Account.key)
+
+ const actualId = await p.select({
+ message: `${sb1Account.name} (${sb1Account.balance} ${sb1Account.currencyCode})`,
+ options: [
+ { value: null, label: "Skip" },
+ ...openActualAccounts.map(a => ({ value: a.id, label: a.name }))
+ ],
+ initialValue: existing?.actualId ?? null,
+ })
+
+ if (p.isCancel(actualId)) {
+ p.cancel("Cancelled.")
+ process.exit(0)
+ }
+
+ if (actualId) mappings.push({ sb1Id: sb1Account.key, actualId, label: sb1Account.name })
+ }
+
+ saveConfig({ ...config, mappings })
+
+ p.outro(`Saved ${mappings.length} mapping(s).`)
+}
diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts
new file mode 100644
index 0000000..70cbffb
--- /dev/null
+++ b/cli/src/commands/auth.ts
@@ -0,0 +1,137 @@
+import { randomUUID } from "node:crypto"
+import { spawn } from "node:child_process"
+import { createServer } from "node:http"
+import * as p from "@clack/prompts"
+import { Temporal } from "temporal-polyfill"
+import { loadConfig } from "../config"
+import { loadTokens, saveTokens } from "../tokens"
+import { getAccounts } from "../actual"
+import type { Sb1Tokens } from "../types"
+
+export async function auth() {
+ const config = loadConfig()
+
+ p.intro("Session status")
+
+ reportSb1Status()
+ await reportActualStatus(config)
+
+ const confirmed = await p.confirm({ message: "Re-authenticate with Sparebanken 1?" })
+ if (p.isCancel(confirmed) || !confirmed) {
+ p.outro("Done.")
+ return
+ }
+
+ const { clientId, clientSecret, finInst } = config.sb1
+ const redirectUri = "http://localhost:3123/callback"
+ const state = randomUUID()
+
+ const authorizeUrl = new URL("https://api.sparebank1.no/oauth/authorize")
+ authorizeUrl.searchParams.set("client_id", clientId)
+ authorizeUrl.searchParams.set("state", state)
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri)
+ authorizeUrl.searchParams.set("finInst", finInst)
+ authorizeUrl.searchParams.set("response_type", "code")
+
+ p.log.info(`If the browser doesn't open, visit:\n ${authorizeUrl}`)
+ spawn("open", [authorizeUrl.toString()], { detached: true, stdio: "ignore" })
+
+ const spinner = p.spinner()
+ spinner.start("Waiting for callback...")
+
+ const code = await waitForCallback(3123, state)
+ const tokens = await exchangeCode(code, clientId, clientSecret, redirectUri)
+ saveTokens(tokens)
+
+ spinner.stop("SB1 authenticated.")
+
+ reportSb1Status()
+ await reportActualStatus(config)
+
+ p.outro("Done.")
+}
+
+function reportSb1Status() {
+ const stored = loadTokens()
+ if (!stored) {
+ p.log.warn("SB1: not authenticated")
+ return
+ }
+
+ const now = Temporal.Now.instant()
+
+ const accessExpiry = Temporal.Instant.fromEpochMilliseconds(stored.accessTokenCreated)
+ .add({ seconds: stored.expires_in })
+ const refreshExpiry = Temporal.Instant.fromEpochMilliseconds(stored.refreshTokenCreated)
+ .add({ seconds: stored.refresh_token_expires_in })
+
+ const accessValid = Temporal.Instant.compare(now, accessExpiry) < 0
+ const refreshValid = Temporal.Instant.compare(now, refreshExpiry) < 0
+
+ if (accessValid) {
+ p.log.success(`SB1: access token valid until ${formatInstant(accessExpiry)}`)
+ } else if (refreshValid) {
+ p.log.warn(`SB1: access token expired — refresh token valid until ${formatInstant(refreshExpiry)}`)
+ } else {
+ p.log.error(`SB1: session fully expired — re-authentication required`)
+ }
+}
+
+async function reportActualStatus(config: ReturnType<typeof loadConfig>) {
+ try {
+ const accounts = await getAccounts(config.actual)
+ const open = accounts.filter(a => !a.closed)
+ p.log.success(`Actual: connected — ${open.length} open account(s)`)
+ } catch (e: any) {
+ p.log.error(`Actual: cannot connect — ${e.message}`)
+ }
+}
+
+function formatInstant(instant: Temporal.Instant): string {
+ return instant.toZonedDateTimeISO("Europe/Oslo").toPlainDateTime().toString().replace("T", " ").slice(0, 16)
+}
+
+function waitForCallback(port: number, expectedState: string): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const server = createServer((req, res) => {
+ const url = new URL(req.url!, `http://localhost:${port}`)
+ const code = url.searchParams.get("code")
+ const state = url.searchParams.get("state")
+ const error = url.searchParams.get("error")
+
+ if (error) {
+ res.end("Authentication failed. You can close this tab.")
+ server.close()
+ reject(new Error(`Auth error: ${error}`))
+ return
+ }
+
+ if (!code || state !== expectedState) {
+ res.writeHead(400).end("Invalid callback.")
+ return
+ }
+
+ res.end("Authentication successful! You can close this tab.")
+ server.close()
+ resolve(code)
+ })
+ server.listen(port)
+ })
+}
+
+async function exchangeCode(code: string, clientId: string, clientSecret: string, redirectUri: string): Promise<Sb1Tokens> {
+ const params = new URLSearchParams({
+ client_id: clientId,
+ client_secret: clientSecret,
+ code,
+ redirect_uri: redirectUri,
+ grant_type: "authorization_code",
+ })
+ const res = await fetch("https://api.sparebank1.no/oauth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: params,
+ })
+ if (!res.ok) throw new Error(`Token exchange failed: ${await res.text()}`)
+ return res.json() as Promise<Sb1Tokens>
+}
diff --git a/cli/src/commands/backup.ts b/cli/src/commands/backup.ts
new file mode 100644
index 0000000..4a7e4f0
--- /dev/null
+++ b/cli/src/commands/backup.ts
@@ -0,0 +1,75 @@
+import { mkdirSync, readdirSync, writeFileSync } from "node:fs"
+import { join } from "node:path"
+import * as p from "@clack/prompts"
+import * as actualApi from "@actual-app/api"
+import { CONFIG_DIR, loadConfig } from "../config"
+import { initActual } from "../actual"
+
+const BACKUP_DIR = join(CONFIG_DIR, "backups")
+
+export async function backup() {
+ const config = loadConfig()
+
+ const spinner = p.spinner()
+ spinner.start("Connecting to Actual...")
+ await initActual(config.actual)
+ spinner.stop("Connected.")
+
+ spinner.start("Exporting budget...")
+ const result = await (actualApi as any).internal.send("export-budget")
+ if (result.error) throw new Error(`Export failed: ${result.error}`)
+
+ mkdirSync(BACKUP_DIR, { recursive: true })
+ const filename = `backup-${timestamp()}.zip`
+ const filepath = join(BACKUP_DIR, filename)
+ writeFileSync(filepath, result.data)
+
+ spinner.stop(`Backup saved to ${filepath}`)
+}
+
+export async function restore(args: string[]) {
+ const config = loadConfig()
+
+ const backups = listBackups()
+ if (!backups.length) {
+ p.log.warn(`No backups found in ${BACKUP_DIR}`)
+ return
+ }
+
+ const filepath = await p.select({
+ message: "Select backup to restore",
+ options: backups.map(f => ({ value: join(BACKUP_DIR, f), label: f }))
+ })
+ if (p.isCancel(filepath)) { p.cancel("Cancelled."); process.exit(0) }
+
+ const confirmed = await p.confirm({
+ message: `Restore ${filepath}? This will overwrite your current budget.`
+ })
+ if (p.isCancel(confirmed) || !confirmed) { p.cancel("Cancelled."); process.exit(0) }
+
+ const spinner = p.spinner()
+ spinner.start("Connecting to Actual...")
+ await initActual(config.actual)
+
+ spinner.start("Restoring backup...")
+ const result = await (actualApi as any).internal.send("import-budget", { filepath, type: "actual" })
+ if (result?.error) throw new Error(`Restore failed: ${result.error}`)
+
+ await actualApi.sync()
+ spinner.stop("Restored and synced.")
+}
+
+function listBackups(): string[] {
+ try {
+ return readdirSync(BACKUP_DIR)
+ .filter(f => f.endsWith(".zip"))
+ .sort()
+ .reverse()
+ } catch {
+ return []
+ }
+}
+
+function timestamp(): string {
+ return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
+}
diff --git a/cli/src/commands/import.ts b/cli/src/commands/import.ts
new file mode 100644
index 0000000..ac39edb
--- /dev/null
+++ b/cli/src/commands/import.ts
@@ -0,0 +1,45 @@
+import { Temporal } from "temporal-polyfill"
+import { loadConfig } from "../config"
+import { createSb1Client } from "../sb1"
+import { importTransactions } from "../actual"
+
+export async function runImport(args: string[]) {
+ const dryRun = args.includes("--dry-run")
+ const since = args.find(a => a.startsWith("--since="))?.split("=")[1]
+
+ if (since) {
+ try {
+ Temporal.PlainDate.from(since, { overflow: "reject" })
+ } catch {
+ throw new Error(`Invalid --since date "${since}". Expected a valid date in YYYY-MM-DD format.`)
+ }
+ }
+
+ const config = loadConfig()
+
+ if (!config.mappings.length) {
+ throw new Error("No account mappings configured. Run `sb1-actual accounts` to see available accounts, then add mappings to config.json.")
+ }
+
+ const sb1 = createSb1Client(config.sb1)
+
+ if (dryRun) console.log("Dry run — no transactions will be written.\n")
+ if (since) console.log(`Fetching transactions since ${since}\n`)
+
+ for (const mapping of config.mappings) {
+ const label = mapping.label ?? mapping.sb1Id
+ console.log(`Fetching transactions for ${label}...`)
+
+ const transactions = await sb1.getTransactions(mapping.sb1Id, since)
+ if (!transactions.length) {
+ console.log(` No transactions found.\n`)
+ continue
+ }
+
+ const booked = transactions.filter(t => t.bookingStatus === "BOOKED")
+ console.log(` ${booked.length} booked transaction(s) found.`)
+
+ const result = await importTransactions(config.actual, mapping.actualId, transactions, dryRun)
+ console.log(` Imported: ${result.added?.length ?? 0} added, ${result.updated?.length ?? 0} updated\n`)
+ }
+}
diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts
new file mode 100644
index 0000000..64b85e0
--- /dev/null
+++ b/cli/src/commands/init.ts
@@ -0,0 +1,76 @@
+import * as p from "@clack/prompts"
+import { loadConfig, saveConfig, CONFIG_PATH } from "../config"
+import type { Config } from "../config"
+
+export async function init() {
+ let existing: Partial<Config> = {}
+ try {
+ existing = loadConfig()
+ } catch {}
+
+ p.intro(existing.sb1 ? `Editing config at ${CONFIG_PATH}` : "Setting up sb1-actual")
+
+ const sb1 = await p.group({
+ clientId: () => p.text({
+ message: "SB1 client ID",
+ initialValue: existing.sb1?.clientId,
+ validate: v => v.trim() ? undefined : "Required"
+ }),
+ clientSecret: () => p.text({
+ message: "SB1 client secret",
+ initialValue: existing.sb1?.clientSecret,
+ validate: v => v.trim() ? undefined : "Required"
+ }),
+ finInst: async () => {
+ const known = [
+ { value: "fid-ostlandet", label: "SpareBank 1 Østlandet (fid-ostlandet)" },
+ { value: "custom", label: "Other (enter manually)" },
+ ]
+ const current = existing.sb1?.finInst
+ const selection = await p.select({
+ message: "SB1 financial institution",
+ options: known,
+ initialValue: known.find(o => o.value === current) ? current : "custom",
+ })
+ if (p.isCancel(selection)) onCancel()
+ if (selection !== "custom") return selection as string
+ return p.text({
+ message: "Enter finInst value",
+ initialValue: current,
+ validate: v => v.trim() ? undefined : "Required"
+ }) as Promise<string>
+ },
+ redirectUri: () => {
+ const uri = "http://localhost:3123/callback"
+ p.note(uri, "Redirect URI — register this in the SB1 developer portal")
+ return Promise.resolve(uri)
+ },
+ }, { onCancel })
+
+ const actual = await p.group({
+ host: () => p.text({
+ message: "Actual server URL",
+ initialValue: existing.actual?.host,
+ placeholder: "http://localhost:5006",
+ }),
+ password: () => p.password({
+ message: "Actual password",
+ }),
+ fileId: () => p.text({
+ message: "Actual file ID",
+ initialValue: existing.actual?.fileId,
+ validate: v => v.trim() ? undefined : "Required"
+ }),
+ }, { onCancel })
+
+ const config: Config = { sb1, actual, mappings: existing.mappings ?? [] }
+
+ saveConfig(config)
+
+ p.outro(`Config saved. Run \`sb1-actual auth\` to authenticate with Sparebanken 1.`)
+}
+
+function onCancel() {
+ p.cancel("Cancelled.")
+ process.exit(0)
+}
diff --git a/cli/src/config.ts b/cli/src/config.ts
new file mode 100644
index 0000000..a4c68a4
--- /dev/null
+++ b/cli/src/config.ts
@@ -0,0 +1,53 @@
+import { join } from "node:path"
+import { homedir } from "node:os"
+import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs"
+
+export const CONFIG_DIR = join(homedir(), ".config", "sb1-actual")
+export const CONFIG_PATH = join(CONFIG_DIR, "config.json")
+export const TOKENS_PATH = join(CONFIG_DIR, "tokens.json")
+
+export type AccountMapping = {
+ sb1Id: string
+ actualId: string
+ label?: string
+}
+
+export type Config = {
+ sb1: {
+ clientId: string
+ clientSecret: string
+ finInst: string
+ }
+ actual: {
+ host: string
+ password: string
+ fileId: string
+ }
+ mappings: AccountMapping[]
+}
+
+export function loadConfig(): Config {
+ if (!existsSync(CONFIG_PATH)) {
+ throw new Error(`No config found at ${CONFIG_PATH}\n\nRun \`sb1-actual init\` to create it.`)
+ }
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"))
+}
+
+export function saveConfig(config: Config): void {
+ mkdirSync(CONFIG_DIR, { recursive: true })
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
+}
+
+const exampleConfig: Config = {
+ sb1: {
+ clientId: "YOUR_CLIENT_ID",
+ clientSecret: "YOUR_CLIENT_SECRET",
+ finInst: "YOUR_FIN_INST"
+ },
+ actual: {
+ host: "http://localhost:5006",
+ password: "your-password",
+ fileId: "your-budget-file-id"
+ },
+ mappings: []
+}
diff --git a/cli/src/index.ts b/cli/src/index.ts
new file mode 100644
index 0000000..daedaae
--- /dev/null
+++ b/cli/src/index.ts
@@ -0,0 +1,39 @@
+#!/usr/bin/env tsx
+import { auth } from "./commands/auth"
+import { accounts } from "./commands/accounts"
+import { runImport } from "./commands/import"
+import { init } from "./commands/init"
+import { backup, restore } from "./commands/backup"
+
+const [command, ...args] = process.argv.slice(2)
+
+const commands: Record<string, (args: string[]) => Promise<void>> = {
+ init: () => init(),
+ auth: () => auth(),
+ accounts: () => accounts(),
+ import: (args) => runImport(args),
+ backup: () => backup(),
+ restore: (args) => restore(args),
+}
+
+const handler = commands[command]
+
+if (!handler) {
+ console.log("Usage: sb1-actual <command> [options]")
+ console.log("")
+ console.log("Commands:")
+ console.log(" init Create or edit config")
+ console.log(" auth Authenticate with Sparebanken 1 (opens browser)")
+ console.log(" accounts List accounts from SB1 and Actual, show mappings")
+ console.log(" import Import transactions into Actual")
+ console.log(" import --dry-run Preview import without writing")
+ console.log(" import --since=YYYY-MM-DD Only fetch transactions from this date")
+ console.log(" backup Export budget to ~/.config/sb1-actual/backups/")
+ console.log(" restore Restore budget from a backup")
+ process.exit(1)
+}
+
+handler(args).catch(err => {
+ console.error(`Error: ${err.message}`)
+ process.exit(1)
+})
diff --git a/cli/src/sb1.ts b/cli/src/sb1.ts
new file mode 100644
index 0000000..36c638c
--- /dev/null
+++ b/cli/src/sb1.ts
@@ -0,0 +1,37 @@
+import { getAccessToken } from "./tokens"
+import type { Config } from "./config"
+import type { Sb1Account, Sb1Transaction } from "./types"
+
+export function createSb1Client(config: Config["sb1"]) {
+ async function token() {
+ return getAccessToken(config.clientId, config.clientSecret)
+ }
+
+ return {
+ async getAccounts(): Promise<Sb1Account[]> {
+ const res = await fetch("https://api.sparebank1.no/personal/banking/accounts", {
+ headers: { Authorization: `Bearer ${await token()}` }
+ })
+ if (!res.ok) throw new Error(`Failed to fetch accounts: ${await res.text()}`)
+ const json = await res.json() as { accounts: Sb1Account[] }
+ return json.accounts
+ },
+
+ async getTransactions(accountKey: string, fromDate?: string): Promise<Sb1Transaction[]> {
+ const params = new URLSearchParams({ accountKey })
+ if (fromDate) {
+ params.set("fromDate", fromDate)
+ params.set("Transaction source", "ALL")
+ }
+ const res = await fetch(`https://api.sparebank1.no/personal/banking/transactions?${params}`, {
+ headers: {
+ Authorization: `Bearer ${await token()}`,
+ Accept: "application/vnd.sparebank1.v1+json;charset=utf-8"
+ }
+ })
+ if (!res.ok) throw new Error(`Failed to fetch transactions: ${await res.text()}`)
+ const json = await res.json()
+ return json["transactions"] as Sb1Transaction[]
+ }
+ }
+}
diff --git a/cli/src/tokens.ts b/cli/src/tokens.ts
new file mode 100644
index 0000000..b1918d2
--- /dev/null
+++ b/cli/src/tokens.ts
@@ -0,0 +1,63 @@
+import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs"
+import { Temporal } from "temporal-polyfill"
+import { CONFIG_DIR, TOKENS_PATH } from "./config"
+import type { Sb1Tokens } from "./types"
+
+type StoredTokens = Sb1Tokens & {
+ accessTokenCreated: number
+ refreshTokenCreated: number
+}
+
+export function loadTokens(): StoredTokens | null {
+ if (!existsSync(TOKENS_PATH)) return null
+ return JSON.parse(readFileSync(TOKENS_PATH, "utf8"))
+}
+
+export function saveTokens(tokens: Sb1Tokens): void {
+ mkdirSync(CONFIG_DIR, { recursive: true })
+ const now = Temporal.Now.instant().epochMilliseconds
+ const stored: StoredTokens = { ...tokens, accessTokenCreated: now, refreshTokenCreated: now }
+ writeFileSync(TOKENS_PATH, JSON.stringify(stored, null, 2))
+}
+
+export async function getAccessToken(clientId: string, clientSecret: string): Promise<string> {
+ const stored = loadTokens()
+ if (!stored) throw new Error("Not authenticated. Run `sb1-actual auth` first.")
+
+ const now = Temporal.Now.instant()
+
+ const accessExpiry = Temporal.Instant.fromEpochMilliseconds(stored.accessTokenCreated)
+ .add({ seconds: stored.expires_in })
+
+ if (Temporal.Instant.compare(now, accessExpiry) < 0) {
+ return stored.access_token
+ }
+
+ const refreshExpiry = Temporal.Instant.fromEpochMilliseconds(stored.refreshTokenCreated)
+ .add({ seconds: stored.refresh_token_expires_in })
+
+ if (Temporal.Instant.compare(now, refreshExpiry) >= 0) {
+ throw new Error("Session expired. Run `sb1-actual auth` again.")
+ }
+
+ return refreshAccessToken(stored.refresh_token, clientId, clientSecret)
+}
+
+async function refreshAccessToken(refreshToken: string, clientId: string, clientSecret: string): Promise<string> {
+ console.log("Refreshing access token...")
+ const params = new URLSearchParams({
+ client_id: clientId,
+ client_secret: clientSecret,
+ refresh_token: refreshToken,
+ grant_type: "refresh_token",
+ })
+ const res = await fetch("https://api.sparebank1.no/oauth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: params,
+ })
+ if (!res.ok) throw new Error(`Token refresh failed: ${await res.text()}`)
+ const tokens = await res.json() as Sb1Tokens
+ saveTokens(tokens)
+ return tokens.access_token
+}
diff --git a/cli/src/types.ts b/cli/src/types.ts
new file mode 100644
index 0000000..eea7c9b
--- /dev/null
+++ b/cli/src/types.ts
@@ -0,0 +1,40 @@
+export type Sb1Tokens = {
+ access_token: string
+ expires_in: number
+ refresh_token_expires_in: number
+ refresh_token_absolute_expires_in: number
+ token_type: string
+ refresh_token: string
+}
+
+export type Sb1Account = {
+ key: string
+ accountNumber: string
+ iban: string
+ name: string
+ balance: number
+ availableBalance: number
+ currencyCode: string
+}
+
+export type Sb1Transaction = {
+ id: string
+ nonUniqueId: string
+ description: string
+ cleanedDescription: string
+ amount: number
+ date: number
+ typeCode: string
+ typeText: string
+ currencyCode: string
+ bookingStatus: string
+ accountName: string
+ accountKey: string
+}
+
+export type ActualAccount = {
+ id: string
+ name: string
+ type: string
+ closed: boolean
+}
diff --git a/cli/tsconfig.json b/cli/tsconfig.json
new file mode 100644
index 0000000..12cd9d9
--- /dev/null
+++ b/cli/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "types": ["node"]
+ }
+}