Files
bo_hemma_swish_app/app/main.py
T
2026-08-19 22:07:54 +02:00

182 lines
5.4 KiB
Python

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"