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(root_path_in_servers=False, root_path=os.getenv("ROOT_PATH", ""), 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"