commit 4f24713617c118a6b3ebe0bdc64bd445a349fff5 Author: Jonas Bengtsson Date: Wed Aug 19 20:59:23 2026 +0200 🎉 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5a418ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.venv +__pycache__ +*.pyc +.pytest_cache +.vscode +.env +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d8c32b --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b98d8a9 --- /dev/null +++ b/.vscode/launch.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7127165 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..46241b2 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0c6cf5a --- /dev/null +++ b/app/main.py @@ -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" diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..2606298 --- /dev/null +++ b/app/static/style.css @@ -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; +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..edd5238 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,112 @@ + + + + + + {{ cfg.title }} + + + +
+
+
+

Månadsavi

+

{{ cfg.title }}

+

+ 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. +

+
+
+ +
+

Underlag från config.json

+
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% endfor %} + + + + + + + + +
PostBeloppPeriodAndelMånadskostnadDin andel/månKommentar
{{ row.post }}{{ money(row.amount) }} {{ cfg.currency }}{{ "år" if row.period == "year" else "månad" }}{{ (row.ratio * 100) | round(1) }} %{{ money(row.monthly_amount) }} {{ cfg.currency }}{{ money(row.monthly_share) }} {{ cfg.currency }}{{ row.comment or "" }}
Beräknad kostnadsandel per månad{{ money(total_monthly_share) }} {{ cfg.currency }}
+
+
+ +
+

Beräkna månadens Swish

+
+ +
+ + +
+
+ + {% if calculation %} +
+
+

Beräknad kostnadsandel

+

{{ money(total_monthly_share) }} {{ cfg.currency }}

+
+
+

Inkomsttak

+

{{ money(calculation.income_cap) }} {{ cfg.currency }}

+
+
+

Att betala

+

{{ money(calculation.amount) }} {{ cfg.currency }}

+
+
+ +
+ Swish QR-kod +
+

Swish

+

Mottagare: {{ cfg.swish.payee }}

+

Meddelande: {{ calculation.message }}

+

Belopp: {{ money(calculation.amount) }} {{ cfg.currency }}

+
+ Visa QR-innehåll + {{ calculation.payload }} +
+
+
+ {% endif %} +
+
+ + diff --git a/config.json b/config.json new file mode 100644 index 0000000..d5e0967 --- /dev/null +++ b/config.json @@ -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" + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ab8014d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0561c9d --- /dev/null +++ b/requirements.txt @@ -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