Added Sb1 Actual integration to projects.
All checks were successful
Build and deploy website / build (push) Successful in 39s

- Code style is Catppuccin Mocha
- Added lang tag to project
- Added keywords to project
- Sort projects by latest updated
This commit is contained in:
Martin Berg Alstad 2025-02-27 21:13:01 +01:00
parent 14c65bda05
commit a2584b97a1
Signed by: martials
GPG Key ID: 706F53DD087A91DE
13 changed files with 175 additions and 33 deletions

10
TODO.md
View File

@ -6,6 +6,7 @@
- [ ] Nix Shell
- [ ] Analytics
- [ ] Organize code better
- [ ] Type slug of project
## SEO
- [ ] Meta tags on each page
@ -13,6 +14,7 @@
## Layout
- [ ] Dark mode toggle
- [ ] Navigate using pathname / breadcrumbs
- [ ] Better style for <code /> blocks
## Accessibility
- [ ] All interactable elements have labels
@ -31,10 +33,16 @@
## ~/projects
- [ ] Translate projects
- [ ] NixOS on desktop
- [ ] RSS Feed
## ~/projects/[project]
- [ ] Only use Gitea icon for Gitea links
- [ ] Bachelor project
- [ ] Sparebank1 ActualBudget service
- [ ] More about this website
- [ ] RSS Feed
- [ ] Copy link to h tag and scroll to h tag on load
- [x] External links should open in new tab
## ~/slashes
- [ ] List of all slashes

View File

@ -38,6 +38,11 @@ export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
markdown: {
shikiConfig: {
theme: "catppuccin-mocha",
},
},
env: {
schema: {
DOMAIN: envField.string({ context: "client", access: "public" }),

View File

@ -1,12 +1,8 @@
---
import { getCollection } from "astro:content"
import ProjectGrid from "./ProjectGrid.astro"
import { type CollectionEntry } from "astro:content"
interface Props {
projects: CollectionEntry<"projects">[]
}
const { projects } = Astro.props
const projects = await getCollection("projects")
---
<ProjectGrid projects={projects} />

View File

@ -2,6 +2,7 @@
import type { Project } from "@/types/types"
import type { NavLink } from "@/utils/linking"
import ProjectCard from "./ProjectCard.astro"
import dayjs from "dayjs"
interface Props {
projects: ReadonlyArray<Project>
@ -14,19 +15,26 @@ const baseUrl: NavLink = "/projects"
<div class="flex flex-wrap justify-around">
{
projects.map(
({ data: { title, description, tags, heroImage, heroImageAlt }, id }) => (
<div class="my-5 px-2">
<ProjectCard
title={title}
linkTo={`${baseUrl}/${id}`}
description={description}
tags={tags}
image={heroImage}
imageAlt={heroImageAlt}
/>
</div>
),
)
projects
.toSorted((a, b) =>
dayjs(a.data.updatedAt).isBefore(dayjs(b.data.updatedAt)) ? 1 : -1,
)
.map(
({
data: { title, description, tags, heroImage, heroImageAlt },
id,
}) => (
<div class="my-5 px-2">
<ProjectCard
title={title}
linkTo={`${baseUrl}/${id}`}
description={description}
tags={tags}
image={heroImage}
imageAlt={heroImageAlt}
/>
</div>
),
)
}
</div>

View File

@ -18,6 +18,7 @@ const { project } = Astro.props
const entry = await getEntry("projects", project)
const { Content } = await render(entry!)
const {
lang,
title,
description,
tags,
@ -29,7 +30,10 @@ const {
} = entry!.data
function localeDateString(isoString: string): string {
return dayjs(isoString).locale(languageTag()).format("YYYY-MM-DD")
if (languageTag() === "nb") {
return dayjs(isoString).locale(languageTag()).format("DD/MM/YYYY")
}
return dayjs(isoString).locale(languageTag()).format("DD-MM-YYYY")
}
---
@ -53,5 +57,7 @@ function localeDateString(isoString: string): string {
<GiteaLink href={source} class="my-2" />
<p class="my-2">{description}</p>
<Content />
<div lang={lang}>
<Content />
</div>
</Layout>

View File

@ -5,11 +5,13 @@ const projectCollection = defineCollection({
loader: glob({ pattern: "**\/*.mdx", base: "./src/content/projects" }),
schema: ({ image }) =>
z.object({
lang: z.union([z.literal("en"), z.literal("nb")]),
title: z.string(),
description: z.string(),
heroImage: image(),
heroImageAlt: z.string(),
tags: z.array(z.string()),
keywords: z.array(z.string()),
source: z.string().url(),
createdAt: z.string().date(),
updatedAt: z.string().date(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -1,9 +1,11 @@
---
lang: "en"
title: "Welcome"
description: "Welcome to my homepage / portfolio"
heroImage: "assets/recursive-meme.png"
heroImageAlt: "A recursive meme that says: Self-reference, recursive meme is self-referential"
tags: [Astro, Svelte, TypeScript, I18n, TailwindCSS, Docker]
keywords: []
source: "https://git.martials.no/martials/martials.no"
createdAt: "2024-09-22"
updatedAt: "2025-02-15"

View File

@ -0,0 +1,115 @@
---
lang: "en"
title: "Sparebank1 - Actual Budget"
description: "Automatically import transactions from Sparebank1 bank accounts to Actual Budget."
heroImage: "assets/is_it_worth_the_time.png"
heroImageAlt: "A diagram that shows how much time is saved automating a tasks rather than doing it manually."
tags: [TypeScript, Docker, Node, CronJob, Sqlite, API-management]
keywords: [Finance, Sparebank1, Sparebank1 Utvikling, Sparebank1 API, Actual Budget API]
source: "https://git.martials.no/martials/sparebank1_actual_budget_integration"
createdAt: "2025-02-27"
updatedAt: "2025-02-27"
---
import ExternalLink from "@/components/links/ExternalLink.astro"
## What is it?
<ExternalLink href="https://actualbudget.org/">Actual Budget</ExternalLink> is an open-source budgeting platform, it can very simply be self-hosted and accessed throught the browser.
While <ExternalLink href="https://www.sparebank1.no/nb/bank/privat.html">Sparebank1</ExternalLink> is a Norwegian bank, with a great <ExternalLink href="https://developer.sparebank1.no/#/">developer experience</ExternalLink> and an easy to use API that can be used to fetch transactions for example.
Actual has an API that can be used to interact with your own instance of Actual, like importing transactions.
Which is what the Sparebank1 Actual Budget integration does. It fetches transactions from my accounts in Sparebank1 and imports them automatically into Actual.
The operation runs daily and can be configured to import from multiple accounts at once.
The purpose of this application is to automatically transfer transactions to the budget, to avoid doing it manually often, or once in a while.
<br/>
## The techy stuff
Since the <ExternalLink href="https://actualbudget.org/docs/api/">Actual Budget API</ExternalLink> is an NPM package, the application had to be created in TypeScript.
I looked at options to NodeJS but neither Bun or Deno was compatible with the *better-sqlite3* package which was a dependency to the API,
so the best option for now was NodeJS.
The application runs a cronJob that executes once a day at 1 am. The specific time is not really that important since it fetches transactions from 3-4 days before the day it runs.
This is done to avoid fetching transactions which are not fully cleared yet. Some transactions can even be cleared but the unique Id for the transaction might not be set yet.
Actual uses that unique Id in order to avoid duplicate transactions, and makes it possible to update transactions if the content has changed.
Rules can be defined within the Actual application, so transactions are automatically sorted into the correct categories on import.
The application supports importing multiple accounts from a single user.
This is done by specifying the account keys to Sparebank1 and a equal length array of ids for the Actual accounts.
<br/>
### Low Coupling
In order to make it easy to reuse the same application for other banks, it was created with low coupling in mind.
Interfaces are defined for the different parts of the application, and in order to implement it for any other bank,
the `Bank` interface can be implemented on any class, and requires two methods.
<br/>
```ts
export interface Bank {
fetchTransactions: (
interval: Interval,
...accountKeys: ReadonlyArray<string>
) => Promise<ReadonlyArray<ActualTransaction>>
shutdown: () => Promise<void> | void
}
```
<br/>
### Authentication
Both Sparebank1 and Actual requires different secrets and keys to work.
Actual only requires that secrets are passed into the client that handles the requests, but Sparebank1 requires more effort in order to fetch data.
First it requires a *authentication code* that can only be fetched using my own BankID account.
The authentication code must be swapped for a *refresh token* within 2 minutes or the process must be started over.
The refresh token has a lifetime of a year unless used, so it can be saved for later use.
The refresh token is added as an environmental variable that can be used to fetch the first *access token*.
After that, both the new refresh token and access token is stored in a Sqlite database so they can be reused until they expire.
<br/>
### Deployement
The application runs in a docker container on my [Homelab](/uses).
It is build from a Gitea Act runner that adds the needed secrets and variables from the Gitea instance.
<br/>
```yaml
name: Deploy application
on:
push:
branches: [main]
jobs:
deploy:
runs-on: host
env:
# Secrets and vars
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Run docker-compose
run: docker compose up -d --build
```
<br/>
## The road ahead
Future plans incude adding more features to the application and some more error handling.
Then potentially implementing the integration for other banks or credit cards to automate even more.
I also want to rewrite it in Bun when they implement the needed APIs to get Better-sqlite3 working.

View File

@ -7,7 +7,10 @@ import "@/styles/global.css"
export const prerender = true
export function getStaticPaths(): GetStaticPathsResult {
return [{ params: { project: "homepage" } }]
return [
{ params: { project: "homepage" } },
{ params: { project: "sb1budget" } },
]
}
const { project } = Astro.params

View File

@ -1,12 +1,9 @@
---
import { getCollection } from "astro:content"
import Layout from "@/layouts/Layout.astro"
import MyProjectsPage from "@/components/projects/MyProjectsPage.astro"
import "@/styles/global.css"
const projects = await getCollection("projects")
---
<Layout title="Projects">
<MyProjectsPage projects={projects} />
<MyProjectsPage />
</Layout>

View File

@ -7,7 +7,10 @@ import "@/styles/global.css"
export const prerender = true
export function getStaticPaths(): GetStaticPathsResult {
return [{ params: { project: "homepage" } }]
return [
{ params: { project: "homepage" } },
{ params: { project: "sb1budget" } },
]
}
const { project } = Astro.params

View File

@ -1,12 +1,9 @@
---
import { getCollection } from "astro:content"
import Layout from "@/layouts/Layout.astro"
import MyProjectsPage from "@/components/projects/MyProjectsPage.astro"
import "@/styles/global.css"
const projects = await getCollection("projects")
---
<Layout title="Prosjekter">
<MyProjectsPage projects={projects} />
<MyProjectsPage />
</Layout>