diff --git a/app/main.py b/app/main.py index 5358d2c..994f339 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,7 @@ from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, Orlen from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS from .station_catalog import sync_station_catalog from . import THEMES +from .vat import effective_vat_rate, global_vat_override, save_global_vat_override main = Blueprint("main", __name__) FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") @@ -63,7 +64,8 @@ def dashboard(): totals = {"gross": Decimal("0"), "deductible_vat": Decimal("0"), "final_cost": Decimal("0"), "liters": Decimal("0")} by_vehicle = defaultdict(lambda: {"gross": 0, "payable": 0, "liters": 0}); invoices = defaultdict(lambda: {"gross": 0, "payable": 0, "count": 0}); settlements = {} for e in entries: - c = calculate_costs(e.gross, settings.vat_rate, settings.vat_deduction_percent) + vat_rate = effective_vat_rate(settings.vat_rate, e.fueled_at) + c = calculate_costs(e.gross, vat_rate, settings.vat_deduction_percent) totals["gross"] += c["gross"]; totals["deductible_vat"] += c["deductible_vat"]; totals["final_cost"] += c["final_cost"]; totals["liters"] += Decimal(e.liters) station_policy = e.station_company normal_price = float(e.price_per_liter) @@ -71,21 +73,21 @@ def dashboard(): rule = FuelCardStationRule.query.filter_by(fuel_card_id=e.fuel_card_id, station_company_id=e.station_company_id).first() if e.used_fuel_card and e.fuel_card_id and e.station_company_id else None uses_station_last_price = bool(rule and rule.use_orlen_last_price and e.wholesale_price is not None) if uses_station_last_price: - payable_price = float(e.wholesale_price) * (1 + float(settings.vat_rate) / 100) + payable_price = float(e.wholesale_price) * (1 + float(vat_rate) / 100) if rule: - net = payable_price / (1 + float(settings.vat_rate) / 100) + net = payable_price / (1 + float(vat_rate) / 100) net = net * (1 - float(rule.discount_net_percent or 0) / 100) + float(rule.surcharge_net_per_liter or 0) - payable_price = net * (1 + float(settings.vat_rate) / 100) + payable_price = net * (1 + float(vat_rate) / 100) payable_price = max(payable_price, 0) payable_gross = float(e.liters) * payable_price - settlements[e.id] = {"normal_price": normal_price, "payable_price": payable_price, "normal_gross": e.gross, "payable_gross": payable_gross, "uses_last_price": uses_station_last_price} + settlements[e.id] = {"normal_price": normal_price, "payable_price": payable_price, "normal_gross": e.gross, "payable_gross": payable_gross, "uses_last_price": uses_station_last_price, "vat_rate": float(vat_rate)} by_vehicle[e.vehicle.name]["gross"] += float(c["gross"]); by_vehicle[e.vehicle.name]["payable"] += payable_gross; by_vehicle[e.vehicle.name]["liters"] += float(e.liters) if e.used_fuel_card: key = invoice_period(e.fueled_at, settings.invoice_split_day) if split_enabled else "Cały miesiąc" else: key = "Poza kartą" invoices[key]["gross"] += float(c["gross"]); invoices[key]["payable"] += payable_gross; invoices[key]["count"] += 1 - return render_template("dashboard.html", vehicles=vehicles, entries=entries, totals=totals, month=month, by_vehicle=dict(by_vehicle), invoices=dict(invoices), settings=settings, split_enabled=split_enabled, settlements=settlements) + return render_template("dashboard.html", vehicles=vehicles, entries=entries, totals=totals, month=month, by_vehicle=dict(by_vehicle), invoices=dict(invoices), settings=settings, split_enabled=split_enabled, settlements=settlements, vat_override=global_vat_override()) @main.route("/vehicles", methods=["GET", "POST"]) @login_required @@ -200,13 +202,12 @@ def orlen_data(): chart = {} for fuel in selected_fuels: fuel_rows = [r for r in rows if r.fuel_type == fuel and (fuel != "LPG" or r.region in selected_regions)] - multiplier = 1 + float(settings.vat_rate) / 100 if include_vat else 1 chart_key = fuel if fuel != "LPG" else None if fuel == "LPG": for region in selected_regions: - chart[f"LPG · {region}"] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * multiplier, 4)} for r in fuel_rows if r.region == region] + chart[f"LPG · {region}"] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * (1 + float(effective_vat_rate(settings.vat_rate, r.effective_date)) / 100 if include_vat else 1), 4)} for r in fuel_rows if r.region == region] else: - chart[fuel] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * multiplier, 4)} for r in fuel_rows] + chart[fuel] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * (1 + float(effective_vat_rate(settings.vat_rate, r.effective_date)) / 100 if include_vat else 1), 4)} for r in fuel_rows] return render_template("orlen.html", rows=rows, chart=chart, selected_fuels=selected_fuels, selected_year=selected_year, settings=settings, fuel_types=FUEL_TYPES, include_vat=include_vat, regions=POLISH_REGIONS, selected_regions=selected_regions, show_lpg_regions=show_lpg_regions) @main.post("/orlen/sync") @@ -284,8 +285,24 @@ def admin_companies(): q=q, regions=POLISH_REGIONS, stations=stations, + vat_override=global_vat_override(), ) + +@main.post("/admin/global-vat") +@login_required +@roles("admin") +def update_global_vat(): + try: + save_global_vat_override(request.form) + db.session.commit() + response = ok_response("Zapisano globalne ustawienia VAT") + return response or redirect(url_for("main.admin_companies")) + except ValueError as exc: + db.session.rollback() + response = error_response(str(exc)) + return response or redirect(url_for("main.admin_companies")) + @main.get("/admin/application") @login_required @roles("admin") diff --git a/app/templates/admin_companies.html b/app/templates/admin_companies.html index 48ccabc..b464fea 100644 --- a/app/templates/admin_companies.html +++ b/app/templates/admin_companies.html @@ -7,6 +7,17 @@ {% include '_admin_nav.html' %} +{% if current_user.role == 'admin' %} +
Globalny, czasowy VAT
+
+
+
+
+
+
+
Stawka zastępuje VAT każdej firmy wyłącznie w podanym okresie. Odliczenie 50% przy VAT 8% oznacza odliczenie połowy podatku, czyli 4% podstawy netto.
+
+{% endif %}
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 1d22f8d..07811c5 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,6 +1,7 @@ {% extends 'base.html' %}{% block content %}

Podsumowanie kosztów

{{ settings.name }} · VAT do odliczenia {{ settings.vat_deduction_percent }}%
+{% if vat_override.enabled %}
{{ vat_override.name }}: globalny VAT {{ vat_override.rate }}%{% if vat_override.start %} od {{ vat_override.start.strftime('%d.%m.%Y') }}{% endif %}{% if vat_override.end %} do {{ vat_override.end.strftime('%d.%m.%Y') }}{% endif %}. Rozliczenia są liczone według daty tankowania.
{% endif %}
Brutto{{ '%.2f'|format(totals.gross) }} zł
VAT odliczony{{ '%.2f'|format(totals.deductible_vat) }} zł
Koszt po VAT{{ '%.2f'|format(totals.final_cost) }} zł
Litry{{ '%.2f'|format(totals.liters) }} l

Koszt według pojazdu

Cena na dystrybutorze a kwota do zapłaty według cennika karty.

Brak danych dla wybranego miesiąca.

Okresy faktur

{% if split_enabled %}Podział według dnia {{settings.invoice_split_day}}{% else %}Podział dzienny wyłączony{% endif %}{% for key,row in invoices.items() %}{% else %}{% endfor %}
OkresPozycjeDetalDo zapłaty
{{key}}{{row.count}}{{'%.2f'|format(row.gross)}} zł{{'%.2f'|format(row.payable)}} zł
Brak danych
-

Tankowania

{% for e in entries %}{% set s=settlements[e.id] %}{% else %}{% endfor %}
DataAutoStacjaPaliwoLitryCena detal.Orlen lastDo zapłatyRazem
{{e.fueled_at.strftime('%d.%m.%Y')}}{{e.vehicle.name}}{{e.station or '—'}}{{e.fuel_type}}{{e.liters}}{{'%.4f'|format(s.normal_price)}} zł/l{% if e.wholesale_price %}{{'%.4f'|format(e.wholesale_price|float)}} zł/l{% else %}—{% endif %}{% if s.uses_last_price %}last price {% endif %}{{'%.4f'|format(s.payable_price)}} zł/l{{'%.2f'|format(s.normal_gross)}} zł
{{'%.2f'|format(s.payable_gross)}} zł
Brak tankowań w tym miesiącu.
+

Tankowania

{% for e in entries %}{% set s=settlements[e.id] %}{% else %}{% endfor %}
DataAutoStacjaPaliwoLitryCena detal.Orlen lastDo zapłatyVATRazem
{{e.fueled_at.strftime('%d.%m.%Y')}}{{e.vehicle.name}}{{e.station or '—'}}{{e.fuel_type}}{{e.liters}}{{'%.4f'|format(s.normal_price)}} zł/l{% if e.wholesale_price %}{{'%.4f'|format(e.wholesale_price|float)}} zł/l{% else %}—{% endif %}{% if s.uses_last_price %}last price {% endif %}{{'%.4f'|format(s.payable_price)}} zł/l{{'%.2f'|format(s.vat_rate)}}%{{'%.2f'|format(s.normal_gross)}} zł
{{'%.2f'|format(s.payable_gross)}} zł
Brak tankowań w tym miesiącu.
{% endblock %}{% block scripts %}{% endblock %} diff --git a/app/vat.py b/app/vat.py new file mode 100644 index 0000000..9bd518a --- /dev/null +++ b/app/vat.py @@ -0,0 +1,88 @@ +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +from .extensions import db +from .models import AppSetting + +KEYS = { + "enabled": "global_vat_override_enabled", + "name": "global_vat_override_name", + "rate": "global_vat_override_rate", + "start": "global_vat_override_start", + "end": "global_vat_override_end", +} + + +def _value(key, default=""): + setting = db.session.get(AppSetting, KEYS[key]) + return setting.value if setting else default + + +def global_vat_override(): + rate_raw = _value("rate") + try: + rate = Decimal(rate_raw) if rate_raw != "" else None + except InvalidOperation: + rate = None + return { + "enabled": _value("enabled", "0") == "1", + "name": _value("name", "Projekt rządowy"), + "rate": rate, + "start": _parse_date(_value("start")), + "end": _parse_date(_value("end")), + } + + +def _parse_date(value): + if not value: + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def effective_vat_rate(nominal_rate, on_date=None): + override = global_vat_override() + if not override["enabled"] or override["rate"] is None: + return Decimal(str(nominal_rate)) + if isinstance(on_date, datetime): + on_date = on_date.date() + on_date = on_date or date.today() + if override["start"] and on_date < override["start"]: + return Decimal(str(nominal_rate)) + if override["end"] and on_date > override["end"]: + return Decimal(str(nominal_rate)) + return override["rate"] + + +def save_global_vat_override(form): + enabled = "enabled" in form + name = (form.get("name") or "Projekt rządowy").strip() + rate_raw = (form.get("rate") or "").strip() + start_raw = (form.get("start") or "").strip() + end_raw = (form.get("end") or "").strip() + + if enabled: + try: + rate = Decimal(rate_raw) + except InvalidOperation as exc: + raise ValueError("Podaj prawidłową globalną stawkę VAT.") from exc + if rate < 0 or rate > 100: + raise ValueError("Globalna stawka VAT musi mieścić się w zakresie 0–100%.") + if start_raw and not _parse_date(start_raw): + raise ValueError("Nieprawidłowa data rozpoczęcia.") + if end_raw and not _parse_date(end_raw): + raise ValueError("Nieprawidłowa data zakończenia.") + if start_raw and end_raw and start_raw > end_raw: + raise ValueError("Data zakończenia nie może być wcześniejsza niż rozpoczęcia.") + + values = { + KEYS["enabled"]: "1" if enabled else "0", + KEYS["name"]: name, + KEYS["rate"]: rate_raw, + KEYS["start"]: start_raw, + KEYS["end"]: end_raw, + } + for key, value in values.items(): + db.session.merge(AppSetting(key=key, value=value)) diff --git a/tests/test_app.py b/tests/test_app.py index e56d77b..b43583c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -128,3 +128,38 @@ def test_migration_runner_creates_registry_without_legacy_migrations(tmp_path): assert migrate_database() == [] assert "schema_migration" in inspect(db.engine).get_table_names() assert migrate_database() == [] + +def test_global_vat_override_changes_50_percent_deduction(tmp_path): + from decimal import Decimal + from datetime import date + from app.extensions import db + from app.models import AppSetting + from app.vat import effective_vat_rate + + app = make_app(tmp_path, "global-vat.db") + with app.app_context(): + for key, value in { + "global_vat_override_enabled": "1", + "global_vat_override_name": "CPN", + "global_vat_override_rate": "8", + "global_vat_override_start": "2026-07-01", + "global_vat_override_end": "2026-07-31", + }.items(): + db.session.merge(AppSetting(key=key, value=value)) + db.session.commit() + rate = effective_vat_rate(Decimal("23"), date(2026, 7, 15)) + result = calculate_costs(Decimal("108"), rate, 50) + assert rate == Decimal("8") + assert result["net"] == Decimal("100.00") + assert result["vat"] == Decimal("8.00") + assert result["deductible_vat"] == Decimal("4.00") + assert effective_vat_rate(Decimal("23"), date(2026, 8, 1)) == Decimal("23") + + +def test_only_admin_can_change_global_vat(tmp_path): + app = make_app(tmp_path, "global-vat-auth.db") + client = app.test_client() + client.post('/login', data={'email':'admin@example.com','password':'admin123!'}) + response = client.post('/admin/global-vat', data={'enabled':'on','name':'CPN','rate':'8','start':'2026-07-01','end':'2026-07-31'}, headers={'X-Requested-With':'XMLHttpRequest'}) + assert response.status_code == 200 + assert response.json['ok'] is True