release: publish saldo 0.1.0

This commit is contained in:
2026-04-21 21:17:36 +02:00
commit 6f5e704739
95 changed files with 9196 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from app import create_app
from app.extensions import db
from app.models import NotificationPreference, User
from app.seed import seed_data, seed_demo_data
class TestConfig:
TESTING = True
SECRET_KEY = "test-secret"
DATA_DIR = Path(tempfile.mkdtemp())
SQLALCHEMY_DATABASE_URI = "sqlite://"
SQLALCHEMY_TRACK_MODIFICATIONS = False
VAPID_PUBLIC_KEY = ""
VAPID_PRIVATE_KEY = ""
VAPID_CLAIMS = {"sub": "mailto:test@example.com"}
ALLOCATION_TARGET_RULES = {
"sparen": {"recommended_pct": 0.18, "min_pct": 0.15, "max_pct": 0.20, "label": "Sparen"},
"urlaub": {"recommended_pct": 0.06, "min_pct": 0.05, "max_pct": 0.08, "label": "Urlaub"},
"freizeit": {"recommended_pct": 0.07, "min_pct": 0.05, "max_pct": 0.10, "label": "Freizeit"},
}
DEFAULT_PERSONAL_SPLIT_DESI_PCT = 50.0
STRONG_INCOME_CHANGE_THRESHOLD = 150.0
APP_NAME = "Saldo Test"
CSRF_ENABLED = False
@pytest.fixture
def app():
app = create_app(TestConfig)
with app.app_context():
db.create_all()
seed_data()
seed_demo_data()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def empty_app():
app = create_app(TestConfig)
with app.app_context():
db.create_all()
seed_data()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def empty_client(empty_app):
return empty_app.test_client()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def admin_user(app):
return User.query.filter_by(username="admin").first()
@pytest.fixture
def editor_user(app):
return User.query.filter_by(username="mitglied1").first()
@pytest.fixture
def logged_in_client(client, admin_user):
response = client.post(
"/auth/login",
data={"username": admin_user.username, "password": "testpass"},
follow_redirects=True,
)
assert response.status_code == 200
return client
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from decimal import Decimal
from app.extensions import db
from app.models import Account, AllocationSuggestion, Month
from app.utils.users import personal_account_names
def test_comparison_to_previous_month(app):
service = app.extensions["saldo.month_service"]
april = Month.query.filter_by(label="2026-04").first()
may = service.copy_month(april, 2026, 5, auto_created=True)
may.incomes[0].amount = Decimal("3300.00")
may.entry_values[0].planned_amount = Decimal("950.00")
db.session.commit()
summary = service.compute_summary(may)
assert summary.deltas["income_delta"] == Decimal("200.00")
assert summary.deltas["cost_delta"] == Decimal("30.00")
def test_suggestion_logic_reacts_to_income_change(app):
service = app.extensions["saldo.month_service"]
month = Month.query.filter_by(label="2026-04").first()
month.incomes[0].amount = Decimal("3500.00")
service.refresh_suggestions(month, "Test")
db.session.commit()
suggestions = AllocationSuggestion.query.filter_by(month_id=month.id).all()
assert len(suggestions) == 5
sparen = next(item for item in suggestions if item.target_account.slug == "sparen")
urlaub = next(item for item in suggestions if item.target_account.slug == "urlaub")
freizeit = next(item for item in suggestions if item.target_account.slug == "freizeit")
assert sparen.suggested_amount > Decimal("0.00")
assert urlaub.suggested_amount > Decimal("0.00")
assert freizeit.suggested_amount > Decimal("0.00")
def test_accepting_suggestions_updates_allocations(app):
service = app.extensions["saldo.month_service"]
allocation_service = app.extensions["saldo.allocation_service"]
month = Month.query.filter_by(label="2026-04").first()
service.refresh_suggestions(month, "Test")
allocation_service.accept_all(month)
service.sync_distribution_entries_from_allocations(month)
db.session.commit()
assert all(item.source == "accepted_suggestion" for item in month.allocations if not item.is_locked)
flo_allocation = next(item for item in month.allocations if item.target_account.slug == "persoenlich-flo")
flo_entry_value = next(
item
for item in month.entry_values
if item.entry.name == "Person 1" and item.entry.category.account.slug == "persoenlich-flo"
)
assert flo_entry_value.planned_amount == flo_allocation.amount
def test_personal_distribution_entry_updates_matching_allocation(app):
service = app.extensions["saldo.month_service"]
month = Month.query.filter_by(label="2026-04").first()
flo_value = next(
item
for item in month.entry_values
if item.entry.name == "Person 1" and item.entry.category.account.slug == "persoenlich-flo"
)
flo_value.planned_amount = Decimal("333.33")
changed = service.sync_distribution_allocation_from_entry(
month,
flo_value.entry,
mark_manual=True,
)
db.session.commit()
flo_allocation = next(item for item in month.allocations if item.target_account.slug == "persoenlich-flo")
personal_labels = personal_account_names()
assert changed is True
assert flo_allocation.amount == Decimal("333.33")
assert flo_allocation.label == personal_labels["persoenlich-flo"]
assert flo_allocation.source == "manual"
def test_personal_split_changes_personal_suggestions(app):
service = app.extensions["saldo.month_service"]
month = Month.query.filter_by(label="2026-04").first()
month.personal_split_desi_pct = Decimal("40.00")
service.refresh_suggestions(month, "Split angepasst")
db.session.commit()
flo_suggestion = next(item for item in month.suggestions if item.target_account.slug == "persoenlich-flo")
desi_suggestion = next(item for item in month.suggestions if item.target_account.slug == "persoenlich-desi")
assert flo_suggestion.suggested_amount > desi_suggestion.suggested_amount
def test_manual_priority_accounts_leave_remaining_suggestions_for_personal_only(app):
service = app.extensions["saldo.month_service"]
month = Month.query.filter_by(label="2026-04").first()
service.refresh_suggestions(month, "Neu berechnen")
db.session.commit()
allocations = {item.target_account.slug: item.amount for item in month.allocations}
suggestions = {item.target_account.slug: item.suggested_amount for item in month.suggestions}
summary = service.compute_summary(month)
assert suggestions["sparen"] >= allocations["sparen"]
assert suggestions["urlaub"] >= allocations["urlaub"]
assert suggestions["freizeit"] >= allocations["freizeit"]
assert suggestions["persoenlich-flo"] == allocations["persoenlich-flo"]
assert suggestions["persoenlich-desi"] == allocations["persoenlich-desi"]
assert summary.remainder == Decimal("0.00")
def test_personal_allocation_is_reduced_when_fixed_costs_rise(app):
service = app.extensions["saldo.month_service"]
month = Month.query.filter_by(label="2026-04").first()
fixed_cost_value = next(item for item in month.entry_values if item.entry.name == "Miete")
original_fixed_cost = fixed_cost_value.planned_amount
original_total = sum(
item.amount
for item in month.allocations
if item.target_account.slug in {"persoenlich-flo", "persoenlich-desi"}
)
fixed_cost_value.planned_amount = original_fixed_cost + Decimal("50.00")
service.refresh_suggestions(month, "Fixkosten gestiegen")
db.session.commit()
personal_total = sum(
item.amount
for item in month.allocations
if item.target_account.slug in {"persoenlich-flo", "persoenlich-desi"}
)
summary = service.compute_summary(month)
assert personal_total == original_total - Decimal("50.00")
assert summary.remainder == Decimal("0.00")
def test_personal_allocation_can_fall_to_zero_before_remainder_turns_negative(app):
service = app.extensions["saldo.month_service"]
month = Month.query.filter_by(label="2026-04").first()
fixed_cost_value = next(item for item in month.entry_values if item.entry.name == "Miete")
fixed_cost_value.planned_amount = Decimal("9999.99")
service.refresh_suggestions(month, "Defizit")
db.session.commit()
flo_allocation = next(item for item in month.allocations if item.target_account.slug == "persoenlich-flo")
desi_allocation = next(item for item in month.allocations if item.target_account.slug == "persoenlich-desi")
summary = service.compute_summary(month)
assert flo_allocation.amount == Decimal("0.00")
assert desi_allocation.amount == Decimal("0.00")
assert summary.remainder < Decimal("0.00")
def test_seeded_distribution_entries_are_marked_as_allocation_targets(app):
month = Month.query.filter_by(label="2026-04").first()
target_entries = {
item.entry.name: item.entry.is_allocation_target
for item in month.entry_values
if item.entry.name in {"Sparziel", "Reisebudget", "Freizeitbudget", "Person 1", "Person 2"}
}
assert target_entries["Sparziel"] is True
assert target_entries["Reisebudget"] is True
assert target_entries["Freizeitbudget"] is True
assert target_entries["Person 1"] is True
assert target_entries["Person 2"] is True
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import date
from decimal import Decimal
from app.extensions import db
from app.models import Month
def test_auto_creates_missing_month(app):
service = app.extensions["saldo.month_service"]
month = service.ensure_month(date(2026, 5, 1))
assert month.label == "2026-05"
assert month.auto_created is True
def test_copy_previous_month_keeps_values(app):
service = app.extensions["saldo.month_service"]
april = Month.query.filter_by(label="2026-04").first()
copied = service.copy_month(april, 2026, 6, auto_created=False)
db.session.commit()
assert copied.label == "2026-06"
assert len(copied.entry_values) == len(april.entry_values)
assert copied.incomes[0].amount == april.incomes[0].amount
def test_remainder_calculation(app):
service = app.extensions["saldo.month_service"]
month = Month.query.filter_by(label="2026-04").first()
summary = service.compute_summary(month)
assert summary.total_income == Decimal("5550.00")
assert summary.remainder == summary.total_income - summary.total_costs
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from decimal import Decimal
from app.extensions import db
from app.models import AllocationSuggestion, Category, CommunityAccount, Month, MonthlyEntryValue
def test_health_route(client):
response = client.get("/health")
assert response.status_code == 200
assert response.json["status"] == "ok"
def test_first_run_redirects_to_setup(empty_client):
response = empty_client.get("/", follow_redirects=False)
assert response.status_code == 302
assert "/auth/setup" in response.headers["Location"]
def test_current_month_is_available_after_login(logged_in_client):
response = logged_in_client.get("/")
assert response.status_code == 200
assert b"2026-04" in response.data or b"2026-05" in response.data
assert b"Dauerauftr\xc3\xa4ge pr\xc3\xbcfen" in response.data
assert b"Extern mitzuteilen" in response.data
def test_analytics_route_uses_new_cost_focused_sections(logged_in_client):
response = logged_in_client.get("/analytics")
content = response.get_data(as_text=True)
assert response.status_code == 200
assert "Kategorien im Monat" in content
assert "Kosten nach Zuordnung" in content
assert "Budgets im Monatsverlauf" in content
assert "Größte Einträge im Monat" in content
assert "Sparkonten" in content
assert "category-chart-back" in content
assert "entry-drilldown-chart" not in content
assert '"Pers\\u00f6nliche Auszahlung"' in content
assert "Person A" in content
assert "Person B" in content
def test_planning_detail_refreshes_stale_suggestions_after_distribution_sync(logged_in_client):
month = Month.query.filter_by(label="2026-04").first()
sparen_value = next(
item for item in month.entry_values if item.entry.name == "Sparziel"
)
sparen_allocation = next(
item for item in month.allocations if item.target_account.slug == "sparen"
)
sparen_value.planned_amount = Decimal("0.00")
sparen_allocation.amount = Decimal("1539.20")
for suggestion in month.suggestions:
suggestion.suggested_amount = Decimal("0.00")
db.session.commit()
response = logged_in_client.get("/planning/2026-04")
db.session.refresh(sparen_value)
refreshed_suggestions = AllocationSuggestion.query.filter_by(month_id=month.id).all()
assert response.status_code == 200
assert sparen_value.planned_amount == Decimal("1539.20")
assert sum((item.suggested_amount for item in refreshed_suggestions), Decimal("0.00")) > Decimal("0.00")
def test_community_entry_can_be_updated_via_annual_amount(app, logged_in_client):
month = Month.query.filter_by(label="2026-04").first()
value = next(
item for item in month.entry_values if item.entry.name == "Miete"
)
response = logged_in_client.post(
f"/planning/{month.label}/entry",
data={
"value_id": value.id,
"return_dialog": f"category-dialog-{value.entry.category_id}",
"entry_name": value.entry.name,
"category_id": value.entry.category_id,
"planned_amount": "",
"annual_amount": "1800.00",
"benefit_scope": value.entry.benefit_scope,
"note": value.note or "",
},
)
updated_value = db.session.get(MonthlyEntryValue, value.id)
assert response.status_code == 302
assert updated_value.planned_amount == Decimal("150.00")
def test_distribution_dialog_shows_direct_budget_form(logged_in_client):
response = logged_in_client.get("/planning/2026-04")
assert response.status_code == 200
assert b"Budget direkt anpassen" in response.data
assert b"Sparkonto" in response.data
def test_planning_shows_budgets_and_community_accounts(logged_in_client):
response = logged_in_client.get("/planning/2026-04")
assert response.status_code == 200
assert b"Budgets" in response.data
assert b"Gemeinschaftskonten" in response.data
assert b"Privatkonto 1" in response.data
assert b"Privatkonto 2" in response.data
def test_community_account_can_assign_budget_categories(logged_in_client):
community_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
category = Category.query.filter_by(slug="wohnen").first()
response = logged_in_client.post(
f"/planning/2026-04/community-accounts/{community_account.id}",
data={
"name": community_account.name,
"description": community_account.description or "",
"category_ids": [str(category.id)],
},
)
db.session.refresh(category)
assert response.status_code == 302
assert category.community_account_id == community_account.id
def test_community_account_rejects_budget_assigned_to_other_account(logged_in_client):
primary_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
secondary_response = logged_in_client.post(
"/planning/2026-04/community-accounts",
data={"name": "Fixkostenkonto", "description": ""},
follow_redirects=True,
)
assert secondary_response.status_code == 200
secondary_account = CommunityAccount.query.filter_by(slug="fixkostenkonto").first()
category = Category.query.filter_by(slug="wohnen").first()
category.community_account_id = primary_account.id
db.session.commit()
response = logged_in_client.post(
f"/planning/2026-04/community-accounts/{secondary_account.id}",
data={
"name": secondary_account.name,
"description": secondary_account.description or "",
"category_ids": [str(category.id)],
},
follow_redirects=True,
)
db.session.refresh(category)
assert response.status_code == 200
assert category.community_account_id == primary_account.id
assert b"bereits anderen Konten zugewiesen" in response.data
def test_community_account_can_be_deleted_and_unassigns_budgets(logged_in_client):
community_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
category = Category.query.filter_by(slug="wohnen").first()
category.community_account_id = community_account.id
db.session.commit()
response = logged_in_client.post(
f"/planning/2026-04/community-accounts/{community_account.id}/delete"
)
db.session.refresh(category)
db.session.refresh(community_account)
assert response.status_code == 302
assert community_account.is_active is False
assert category.community_account_id is None
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import io
from datetime import date
from decimal import Decimal
from app.extensions import db
from app.models import CostParticipant, Entry, InAppNotification, Month, User
def test_share_logic_with_external_guest(app):
month = Month.query.filter_by(label="2026-04").first()
service = app.extensions["saldo.share_service"]
shared_value = next(item for item in month.entry_values if item.entry.name == "Streaming 1")
result = service.calculate_entry_shares(shared_value)
guest_share = next(item for item in result["shares"] if item["participant_name"] == "Gast")
assert guest_share["amount"] == Decimal("5.99")
assert result["external_total"] == guest_share["amount"]
def test_external_guest_shares_do_not_reduce_distribution_pool(app):
month = Month.query.filter_by(label="2026-04").first()
service = app.extensions["saldo.month_service"]
shared_value = next(item for item in month.entry_values if item.entry.name == "Streaming 1")
summary = service.compute_summary(month)
assert shared_value.entry.share_rules
assert summary.fixed_costs < summary.total_costs
def test_push_reminder_logic_creates_in_app_notifications(app):
notification_service = app.extensions["saldo.notification_service"]
count = notification_service.run_monthly_checks(date(2026, 4, 27))
assert count >= 1
assert InAppNotification.query.count() >= 1
def test_setup_route_can_create_first_admin(empty_client, empty_app):
response = empty_client.post(
"/auth/setup",
data={
"username": "setup-admin",
"display_name": "Setup Admin",
"email": "setup-admin@example.invalid",
"password": "supersecret",
"password_confirm": "supersecret",
},
follow_redirects=True,
)
with empty_app.app_context():
user = User.query.filter_by(username="setup-admin").first()
assert user is not None
assert user.role == "admin"
assert response.status_code == 200
assert "2026-04" in response.get_data(as_text=True) or "2026-05" in response.get_data(as_text=True)
def test_admin_route_requires_admin(client, app, editor_user):
client.post(
"/auth/login",
data={"username": editor_user.username, "password": "testpass"},
follow_redirects=True,
)
response = client.get("/admin/")
assert response.status_code == 403
def test_admin_can_access_admin_route(logged_in_client):
response = logged_in_client.get("/admin/")
assert response.status_code == 200
def test_admin_can_create_entry_and_backfill_existing_month(logged_in_client, app):
from app.models import Category, Entry, Month, MonthlyEntryValue
category = Category.query.filter_by(slug="haushalt").first()
month = Month.query.filter_by(label="2026-04").first()
before = MonthlyEntryValue.query.filter_by(month_id=month.id).count()
response = logged_in_client.post(
"/admin/entries",
data={
"category_id": category.id,
"name": "Tierfutter",
"slug": "tierfutter",
"default_amount": "45.00",
"amount_type": "fixed",
"sort_order": "99",
"description": "",
"is_active": "on",
},
follow_redirects=True,
)
assert response.status_code == 200
entry = Entry.query.filter_by(slug="tierfutter").first()
assert entry is not None
assert MonthlyEntryValue.query.filter_by(month_id=month.id).count() == before + 1
def test_participant_avatar_can_be_uploaded(logged_in_client, app):
response = logged_in_client.post(
"/planning/2026-04/participants",
data={
"name": "Mika",
"is_external": "on",
"return_dialog": "split-people-dialog",
"avatar_file": (io.BytesIO(b"fake-image-bytes"), "mika.png"),
},
content_type="multipart/form-data",
follow_redirects=True,
)
participant = CostParticipant.query.filter_by(name="Mika").first()
assert response.status_code == 200
assert participant is not None
assert participant.avatar_url is not None
assert participant.avatar_url.startswith("/media/avatars/")