🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-08-19 20:59:23 +02:00
commit 4f24713617
11 changed files with 875 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.github
.venv
__pycache__
*.pyc
.pytest_cache
.vscode
.env
README.md
+195
View File
@@ -0,0 +1,195 @@
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python
+20
View File
@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: FastAPI",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"app.main:app",
"--reload"
],
"jinja": true
}
]
}
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
CONFIG_PATH=/app/config.json
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY config.json ./config.json
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+76
View File
@@ -0,0 +1,76 @@
# Bo hemma-avi med Swish-QR
En enkel Docker-app som:
- läser kostnadsposter från `config.json`
- visar underlaget på en webbsida
- låter användaren mata in månadens nettoinkomst
- räknar fram `min(kostnadsandel, nettoinkomst * income_ratio, eventuell max_amount)`
- genererar en Swish-QR-kod med förifyllt belopp och meddelande
## Starta
```bash
docker compose up --build
```
Öppna sedan:
```text
http://localhost:8000
```
## Konfiguration
Exempel:
```json
{
"title": "Bo hemma-avi",
"currency": "SEK",
"swish": {
"payee": "46701234567",
"message_prefix": "Bohemma",
"lock_mask": 0
},
"payment": {
"income_ratio": 0.30,
"max_amount": null,
"round_to": 1
},
"items": [
{
"post": "hushållsel",
"amount": 1000,
"period": "month",
"ratio": 0.2,
"comment": "20 % av uppskattad månadskostnad"
},
{
"post": "hemförsäkring",
"amount": 5000,
"period": "year",
"ratio": 0.2,
"comment": "20 % av årskostnad, omräknad till månad"
}
]
}
```
### Fält
| Fält | Betydelse |
|---|---|
| `swish.payee` | Swishnummer, t.ex. `46701234567` |
| `swish.message_prefix` | Prefix i meddelandet, appen lägger till aktuell period, t.ex. `Bohemma 2026-07` |
| `swish.lock_mask` | `0` låser mottagare, belopp och meddelande |
| `payment.income_ratio` | Andel av nettoinkomst som är tak, t.ex. `0.30` |
| `payment.max_amount` | Extra absolut maxbelopp, eller `null` |
| `payment.round_to` | Avrundning uppåt, t.ex. `1`, `10`, `50` eller `100` |
| `items[].amount` | Kostnad, per månad eller år beroende på `period` |
| `items[].period` | `month` eller `year` |
| `items[].ratio` | Andel dottern ska bära, t.ex. `0.2` eller `1` |
## Viktigt
Appen genomför ingen betalning. QR-koden förifyller betalningen i Swish, men betalaren måste själv granska och godkänna betalningen.
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import base64
import io
import json
import math
import os
from datetime import date
from pathlib import Path
from typing import Literal
from urllib.parse import quote
import qrcode
from fastapi import FastAPI, Query, Request
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field, field_validator, model_validator
import uvicorn
class CostItem(BaseModel):
post: str = Field(min_length=1)
amount: float = Field(ge=0)
period: Literal["month", "year"] = "month"
ratio: float = Field(ge=0, le=1)
comment: str | None = None
@property
def monthly_amount(self) -> float:
return self.amount / 12 if self.period == "year" else self.amount
@property
def monthly_share(self) -> float:
return self.monthly_amount * self.ratio
class SwishConfig(BaseModel):
payee: str = Field(min_length=8, description="Swishnummer, helst med landskod utan +, t.ex. 46701234567")
message_prefix: str = "Bohemma"
lock_mask: int = Field(default=0, ge=0, description="0 låser mottagare, belopp och meddelande i Swish-QR-formatet.")
@field_validator("payee")
@classmethod
def normalize_payee(cls, value: str) -> str:
return value.replace("+", "").replace(" ", "").replace("-", "")
class PaymentConfig(BaseModel):
income_ratio: float = Field(default=0.30, ge=0, le=1)
max_amount: float | None = Field(default=None, ge=0)
round_to: float = Field(default=1.0, ge=0.0)
class AppConfig(BaseModel):
title: str = "Bo hemma-avi"
currency: str = "SEK"
swish: SwishConfig
payment: PaymentConfig = PaymentConfig()
items: list[CostItem]
@model_validator(mode="after")
def ensure_items(self) -> "AppConfig":
if not self.items:
raise ValueError("config.json måste innehålla minst en kostnadspost i items.")
return self
app = FastAPI(title="Bo hemma-avi")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
def config_path() -> Path:
#return Path(os.getenv("CONFIG_PATH", "/app/config.json"))
return Path(os.getenv("CONFIG_PATH", "config.json"))
def load_config() -> AppConfig:
path = config_path()
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
return AppConfig.model_validate(data)
def money(value: float) -> str:
rounded = round(value, 2)
if rounded == int(rounded):
return f"{int(rounded):,}".replace(",", " ")
return f"{rounded:,.2f}".replace(",", " ").replace(".", ",")
def swish_amount(value: float) -> str:
return f"{value:.2f}"#.replace(".", ",")
def round_to(value: float, round_to: float) -> float:
return float(math.ceil(value / round_to) * round_to)
def current_period() -> str:
today = date.today()
return f"{today.year}-{today.month:02d}"
def build_swish_payload(payee: str, amount: float, message: str, lock_mask: int) -> str:
# Swish QR text payload. Meddelandet URL-kodas, mellanslag blir %20.
return f"C{payee};{swish_amount(amount)};{quote(message, safe=' ')};{lock_mask}"
def qr_data_uri(payload: str) -> str:
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.ERROR_CORRECT_M,
box_size=10,
border=3,
)
qr.add_data(payload)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
image.save(buffer, "PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
@app.get("/", response_class=HTMLResponse)
def index(request: Request, nettoinkomst: float | None = Query(default=None, ge=0)):
cfg = load_config()
rows = []
total_monthly_share = 0.0
for item in cfg.items:
row = {
"post": item.post,
"amount": item.amount,
"period": item.period,
"ratio": item.ratio,
"monthly_amount": item.monthly_amount,
"monthly_share": item.monthly_share,
"comment": item.comment,
}
rows.append(row)
total_monthly_share += item.monthly_share
calculation = None
if nettoinkomst is not None:
income_cap = nettoinkomst * cfg.payment.income_ratio
candidates = [total_monthly_share, income_cap]
if cfg.payment.max_amount is not None:
candidates.append(cfg.payment.max_amount)
amount = round_to(min(candidates), cfg.payment.round_to)
message = f"{cfg.swish.message_prefix} {current_period()}"
payload = build_swish_payload(cfg.swish.payee, amount, message, cfg.swish.lock_mask)
calculation = {
"nettoinkomst": nettoinkomst,
"income_ratio": cfg.payment.income_ratio,
"income_cap": income_cap,
"amount": amount,
"message": message,
"payload": payload,
"qr": qr_data_uri(payload),
}
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"cfg": cfg,
"rows": rows,
"total_monthly_share": total_monthly_share,
"calculation": calculation,
"money": money,
},
)
@app.get("/health", response_class=PlainTextResponse)
def health() -> str:
return "ok"
+170
View File
@@ -0,0 +1,170 @@
:root {
color-scheme: light;
--bg: #f6f7f9;
--card: #ffffff;
--text: #17202a;
--muted: #657080;
--line: #dde3ea;
--accent: #0d6efd;
--accent-dark: #084fb4;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1100px;
margin: 0 auto;
padding: 32px 18px 64px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 24px;
margin: 18px 0;
box-shadow: 0 10px 30px rgba(23, 32, 42, 0.06);
}
.hero {
background: linear-gradient(135deg, #ffffff, #edf4ff);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent);
font-weight: 700;
margin: 0 0 8px;
}
h1, h2, h3 { margin-top: 0; }
.lead {
color: var(--muted);
max-width: 760px;
font-size: 1.08rem;
}
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
min-width: 850px;
}
th, td {
padding: 12px 10px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
font-size: 0.88rem;
color: var(--muted);
font-weight: 700;
}
tfoot td {
font-weight: 800;
background: #f9fbff;
}
.num { text-align: right; white-space: nowrap; }
.strong { font-weight: 800; }
.form label {
display: block;
font-weight: 700;
margin-bottom: 8px;
}
.input-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
input {
flex: 1 1 240px;
font: inherit;
padding: 13px 14px;
border: 1px solid var(--line);
border-radius: 12px;
}
button {
border: 0;
border-radius: 12px;
padding: 13px 18px;
font: inherit;
font-weight: 800;
color: white;
background: var(--accent);
cursor: pointer;
}
button:hover { background: var(--accent-dark); }
.result {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
margin-top: 22px;
}
.result > div {
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
background: #fbfcff;
}
.label {
color: var(--muted);
margin: 0 0 6px;
font-size: 0.92rem;
}
.big {
margin: 0;
font-weight: 850;
font-size: 1.45rem;
}
.accent { color: var(--accent); }
.qr-block {
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: center;
margin-top: 24px;
padding: 18px;
border: 1px solid var(--line);
border-radius: 16px;
}
.qr-block img {
width: 230px;
height: 230px;
image-rendering: crisp-edges;
}
code {
display: block;
margin-top: 10px;
padding: 12px;
background: #f1f3f6;
border-radius: 10px;
overflow-x: auto;
}
+112
View File
@@ -0,0 +1,112 @@
<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ cfg.title }}</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
<main class="page">
<section class="hero card">
<div>
<p class="eyebrow">Månadsavi</p>
<h1>{{ cfg.title }}</h1>
<p class="lead">
Mata in månadens nettoinkomst. Sidan räknar ut minsta belopp av
beräknad kostnadsandel och {{ (cfg.payment.income_ratio * 100) | round | int }} % av nettoinkomsten.
</p>
</div>
</section>
<section class="card">
<h2>Underlag från config.json</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Post</th>
<th>Belopp</th>
<th>Period</th>
<th>Andel</th>
<th>Månadskostnad</th>
<th>Din andel/mån</th>
<th>Kommentar</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.post }}</td>
<td class="num">{{ money(row.amount) }} {{ cfg.currency }}</td>
<td>{{ "år" if row.period == "year" else "månad" }}</td>
<td class="num">{{ (row.ratio * 100) | round(1) }} %</td>
<td class="num">{{ money(row.monthly_amount) }} {{ cfg.currency }}</td>
<td class="num strong">{{ money(row.monthly_share) }} {{ cfg.currency }}</td>
<td>{{ row.comment or "" }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="5">Beräknad kostnadsandel per månad</td>
<td class="num strong">{{ money(total_monthly_share) }} {{ cfg.currency }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</section>
<section class="card">
<h2>Beräkna månadens Swish</h2>
<form method="get" class="form">
<label for="nettoinkomst">Månadens nettoinkomst</label>
<div class="input-row">
<input
id="nettoinkomst"
name="nettoinkomst"
type="number"
min="0"
step="1"
placeholder="Ex. 12000"
value="{{ calculation.nettoinkomst | int if calculation else '' }}"
required>
<button type="submit">Skapa QR-kod</button>
</div>
</form>
{% if calculation %}
<div class="result">
<div>
<p class="label">Beräknad kostnadsandel</p>
<p class="big">{{ money(total_monthly_share) }} {{ cfg.currency }}</p>
</div>
<div>
<p class="label">Inkomsttak</p>
<p class="big">{{ money(calculation.income_cap) }} {{ cfg.currency }}</p>
</div>
<div>
<p class="label">Att betala</p>
<p class="big accent">{{ money(calculation.amount) }} {{ cfg.currency }}</p>
</div>
</div>
<div class="qr-block">
<img src="{{ calculation.qr }}" alt="Swish QR-kod">
<div>
<h3>Swish</h3>
<p><strong>Mottagare:</strong> {{ cfg.swish.payee }}</p>
<p><strong>Meddelande:</strong> {{ calculation.message }}</p>
<p><strong>Belopp:</strong> {{ money(calculation.amount) }} {{ cfg.currency }}</p>
<details>
<summary>Visa QR-innehåll</summary>
<code>{{ calculation.payload }}</code>
</details>
</div>
</div>
{% endif %}
</section>
</main>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
{
"title": "Bo hemma-avi",
"currency": "SEK",
"swish": {
"payee": "0706627248",
"message_prefix": "Bohemma",
"lock_mask": 0
},
"payment": {
"income_ratio": 0.30,
"max_amount": null,
"round_to": 0.1
},
"items": [
{
"post": "Mat, exkl lunch",
"amount": 14000,
"period": "month",
"ratio": 0.15,
"comment": "20 % av månadskostnad"
},
{
"post": "Bredband",
"amount": 1000,
"period": "year",
"ratio": 0.25,
"comment": "25 % av årskostnad, omräknad till månad"
},
{
"post": "Hushållsel",
"amount": 5000,
"period": "year",
"ratio": 0.2,
"comment": "20 % av årskostnad, omräknad till månad"
},
{
"post": "Elnätsavgift",
"amount": 5000,
"period": "year",
"ratio": 0.2,
"comment": "20 % av årskostnad, omräknad till månad"
},
{
"post": "VA & sophantering",
"amount": 4000,
"period": "year",
"ratio": 0.20,
"comment": "25 % av årskostnad, omräknad till månad"
},
{
"post": "Hemförsäkring",
"amount": 5000,
"period": "year",
"ratio": 0.2,
"comment": "20 % av årskostnad, omräknad till månad"
},
{
"post": "Mobilabonnemang",
"amount": 149,
"period": "month",
"ratio": 1,
"comment": "100 % av din egen kostnad"
},
{
"post": "Streamingtjänster",
"amount": 500,
"period": "month",
"ratio": 0.25,
"comment": "25 % av månadskostnad"
},
{
"post": "Hemutrustning/slitage",
"amount": 500,
"period": "month",
"ratio": 1,
"comment": "100 % av beräknad månadskostnad"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
services:
bohemma-swish:
build: .
container_name: bohemma-swish
ports:
- "8000:8000"
environment:
CONFIG_PATH: /app/config.json
volumes:
- ./config.json:/app/config.json:ro
restart: unless-stopped
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.111,<1.0
uvicorn[standard]>=0.30,<1.0
jinja2>=3.1,<4.0
qrcode[pil]>=7.4,<8.0
pydantic>=2.7,<3.0