diff options
| author | ivar <i@oiee.no> | 2026-03-09 23:05:38 +0100 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-03-09 23:05:38 +0100 |
| commit | 69448e29a85cad3a94b3be3ad33efbc52764528f (patch) | |
| tree | c32b8c817322fdf26edbbb3fa75b9505a7020ae8 /cli/src | |
| parent | b35302fa020ec82a9d67a6cb34379d42983d3cfc (diff) | |
| download | sparebank1-actualbudget-69448e29a85cad3a94b3be3ad33efbc52764528f.tar.xz sparebank1-actualbudget-69448e29a85cad3a94b3be3ad33efbc52764528f.zip | |
Diffstat (limited to 'cli/src')
| -rw-r--r-- | cli/src/actual.ts | 51 | ||||
| -rw-r--r-- | cli/src/commands/accounts.ts | 46 | ||||
| -rw-r--r-- | cli/src/commands/auth.ts | 137 | ||||
| -rw-r--r-- | cli/src/commands/backup.ts | 75 | ||||
| -rw-r--r-- | cli/src/commands/import.ts | 45 | ||||
| -rw-r--r-- | cli/src/commands/init.ts | 76 | ||||
| -rw-r--r-- | cli/src/config.ts | 53 | ||||
| -rw-r--r-- | cli/src/index.ts | 39 | ||||
| -rw-r--r-- | cli/src/sb1.ts | 37 | ||||
| -rw-r--r-- | cli/src/tokens.ts | 63 | ||||
| -rw-r--r-- | cli/src/types.ts | 40 |
11 files changed, 662 insertions, 0 deletions
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 +} |
