aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src
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 /cli/src
parentb35302fa020ec82a9d67a6cb34379d42983d3cfc (diff)
downloadsparebank1-actualbudget-master.tar.xz
sparebank1-actualbudget-master.zip
Add wip cliHEADmaster
Diffstat (limited to 'cli/src')
-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
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
+}