A self-hosted alternative to Spiir, built on Actual Budget with bank sync via Lunch Flow.
Spiir is shutting down. spirex (Spiir Exit) helps you migrate your data and set up a self-hosted replacement with ongoing bank sync for Danish (and other EU) banks. I have tested it for Lån og Spar so far, and it works.
- Actual Budget - open source, self-hosted personal finance app with envelope budgeting
- Spiir → Actual converter - imports your full Spiir export (accounts, categories, transactions, transfers)
- Lunch Flow - automated bank sync for 2400+ banks including Danish banks
- Docker (or OrbStack on macOS)
- Node.js (for
npx) - Python 3.10+ and uv
cd docker
docker compose up -dOpen http://localhost:5006, set a password, and create a budget.
For a permanent setup on a home server, consider putting Actual behind a reverse proxy with HTTPS.
Download your data from Spiir. Three formats are supported:
| Format | File | Transfer detection |
|---|---|---|
| Full CSV | alle-poster-*.csv |
Yes |
| Simple CSV | simpel-poster-*.csv |
No (no IDs) |
| JSON | alle-poster-*.json |
Yes |
Use the full CSV or JSON export for the best result, they contain IDs needed to correctly detect transfers between accounts.
cd converter
uv sync
uv run python cli.py import /path/to/alle-poster-2026-04-10.csv \
--password YOUR_ACTUAL_PASSWORD \
--budget "The Name of your Budget"Options:
| flag | default | description |
|---|---|---|
--url |
http://localhost:5006 |
Actual Budget server URL |
--password |
(prompted) | Actual Budget password |
--budget |
(required) | Budget name in Actual Budget |
The converter will:
- Parse the Spiir export (auto-detects format)
- Create accounts matching your Spiir accounts
- Create category groups and categories preserving the Spiir hierarchy
- Import all transactions with deduplication (safe to run multiple times)
- Import transfers between accounts
There's an optional command in the cli that can extract rules based on the given data. The rule creation algorithm is very simple:
- group transactions by
description - for each group find the most common (Main category, category)
- if more than X% of the transactions agree on the category, create rule
The default threshold is 80%.
cd converter
uv sync
uv run python cli.py rules /path/to/alle-poster-2026-04-10.csv \
--password YOUR_ACTUAL_PASSWORD \
--budget "The Name of your Budget"Extra options:
| flag | description | default |
|---|---|---|
| --threshold | Minimum confidence to create a rule [0.0, 1.0] | 0.8 |
| --min-count | Minimum number of transactions needed to create a rule | 2 |
When the rules have been inferred, you have the option to either accept all rules, decline them or decide for each individual rule.
Once accepted the rules are imported into Actual Budget.
Lunch Flow connects your bank to Actual Budget via open banking. It supports Danish banks (Danske Bank, Nordea, Jyske Bank, etc.) and 2400+ institutions globally.
- Sign up at lunchflow.app
- Go to Connections and add your bank
- Go to Destinations → Add Destination → select API
- Copy your API token
To have the new transactions look more like the ones we expect from Spiir, we can create a normalization function in Lunch Flow for both the Merchant and Description fields.
- On lunchflow.app go to Connections
- For each account open settings
- Go into the Merchant tab, and enable Advanced Mode.
- Insert the following into the template field
(it.remittanceInformationUnstructuredArray ?? [])
.join(" ")
.replace(
/^(kontaktløs\s+)?(Dankort(-køb)?|MobilePay køb|Betalingsservice|Visa\/Dankort)\s+/i,
"",
)
.replace(/\s+(Nota|Notanr|Aftalenr\.|Trans\.nr\.)\s+\S+$/i, "")
.replace(/\s+Nota\s+nr\.\s+\S+$/i, "")
.replace(/^[A-Z]{3}\s+[\d,.]+\s+Kurs\s+[\d,.]+\s+Nota\s+nr\.\s+\d+\s*/i, "")
.replace(/\s*[-]\s*\d+$/, "")
.replace(/\s+\d+$/, "")
.replace(/\.+$/, "")
.replace(/\s+/g, " ")
.trim() ||
it.creditorName ||
"Unknown";This will normalize the bank text and put it into the Merchant field.
Repeat for the Description tab, using the same template.
In Actual Budget: Settings → Show advanced settings → copy the Sync ID.
npx @lunchflow/actual-flowThe interactive setup will ask for:
- Lunch Flow API token
- Actual Budget server URL
- Actual Budget password
- Actual Budget Sync ID
On first run, select Configure account mappings to map each Lunch Flow bank account to the corresponding Actual Budget account. This ensures transactions from your bank land in the right account. This is also where you choose which date to start syncing from, so don't skip this step.
If you imported from Spiir first, your Actual accounts will already exist, just match them up by name.
Note
The Lunch Flow account names are probably pretty meaningless to you. They are by default just the account types (at least from Lån & Spar), so creating the account mappings becomes a lot easier if you rename the accounts in the Lunch Flow web-UI before configuring the account mappings.
To sync daily, add a cron job:
crontab -e0 8 * * * npx @lunchflow/actual-flow importAdd a launchd agent to your home-manager config:
launchd.agents.actual-sync = {
enable = true;
config = {
Label = "com.spirex.actual-sync";
ProgramArguments = [ "${pkgs.nodejs}/bin/npx" "@lunchflow/actual-flow" "import" ];
StartCalendarInterval = [{ Hour = 8; Minute = 0; }];
StandardOutPath = "/tmp/actual-sync.log";
StandardErrorPath = "/tmp/actual-sync.err";
};
};For NixOS (home server), use a systemd timer instead:
systemd.user.services.actual-sync = {
Unit.Description = "Sync bank transactions to Actual Budget";
Service = {
Type = "oneshot";
ExecStart = "${pkgs.nodejs}/bin/npx @lunchflow/actual-flow import";
};
};
systemd.user.timers.actual-sync = {
Unit.Description = "Daily Actual Budget bank sync";
Timer = {
OnCalendar = "daily";
Persistent = true;
};
Install.WantedBy = [ "timers.target" ];
};spirex/
├── README.md
├── converter/ # Spiir → Actual Budget importer
│ ├── cli.py # CLI entry point
│ ├── spiir_parser.py # Parses all 3 Spiir export formats
│ ├── actual_importer.py # Imports into Actual via actualpy
│ ├── rule_inference.py # Infers categorisation rules from history
│ └── pyproject.toml
└── docker/
└── docker-compose.yaml
This is a pretty simple project, so it's already fairly complete.
However, there are probably many fancier things that could be done to create meaningful rules based on exported data such as:
- better normalization of transaction descriptions
- use some kind of text embeddings to make semantic clusters and derive rules from that
- probably something with large language models
PRs welcome. Run tests with:
cd converter
uv sync
uv run pytestMIT