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