Add budget block allocation screen

This commit is contained in:
JKuijperM 2026-10-21 18:41:05 +02:00
parent e64421b3df
commit d9b0ce4287
8 changed files with 581 additions and 3 deletions

View File

@ -1,3 +1,5 @@
from decimal import Decimal
from django import forms
from .csv_export import DEFAULT_DIALECT
from .models import (
@ -9,6 +11,7 @@ from .models import (
FuelEntry,
Goal,
Transfer,
BudgetBlock,
)
@ -107,7 +110,7 @@ class AccountForm(forms.ModelForm):
class Meta:
model = Account
fields = ["name", "initial_balance", "active"]
fields = ["name", "initial_balance", "active", "is_savings"]
class IncomeForm(_GoalLinksMixin, forms.ModelForm):
@ -290,6 +293,55 @@ class GoalForm(forms.ModelForm):
return cleaned
class BudgetBlockForm(forms.Form):
"""El reparto del ingreso por bloques: un campo por cada uno de los cinco.
La suma no puede pasar de 100, pero no se obliga a que llegue: lo que falte
es "sin asignar", que es información útil y no un error.
"""
def __init__(self, *args, user, **kwargs):
self.user = user
saved = {
row.block: row.percentage
for row in BudgetBlock.objects.filter(owner=user)
}
kwargs.setdefault(
"initial",
# Un bloque sin fila guardada vale 0.
{block: saved.get(block, Decimal("0")) for block, _ in BudgetBlock.BLOCK_CHOICES},
)
super().__init__(*args, **kwargs)
for block, label in BudgetBlock.BLOCK_CHOICES:
self.fields[block] = forms.DecimalField(
label=f"{label} (%)",
min_value=0,
max_value=100,
max_digits=5,
decimal_places=2,
)
def clean(self):
cleaned = super().clean()
values = [cleaned.get(block) for block, _ in BudgetBlock.BLOCK_CHOICES]
# Si algún campo ya falló, su error basta: la suma saldría con huecos.
if None not in values and sum(values) > 100:
raise forms.ValidationError(
"Los bloques suman %s %%; entre todos no pueden pasar del 100 %%."
% sum(values)
)
return cleaned
def save(self):
for block, _ in BudgetBlock.BLOCK_CHOICES:
BudgetBlock.objects.update_or_create(
owner=self.user,
block=block,
defaults={"percentage": self.cleaned_data[block]},
)
class ExportForm(forms.Form):
"""Filtros de la exportación a CSV. Valida la pantalla y la descarga.

View File

@ -14,6 +14,7 @@
<th>Saldo inicial</th>
<th>Saldo actual</th>
<th>Activa</th>
<th>Ahorro</th>
<th></th>
</tr>
</thead>
@ -30,6 +31,11 @@
<span class="badge badge-inactive">Inactiva</span>
{% endif %}
</td>
<td>
{% if row.account.is_savings %}
<span class="badge badge-neutral">De ahorro</span>
{% endif %}
</td>
<td class="table-actions">
<a href="{% url 'account_edit' row.account.id %}">Editar</a>
{% if row.account.active %}
@ -39,7 +45,7 @@
</tr>
{% empty %}
<tr>
<td colspan="5" class="empty-state">
<td colspan="6" class="empty-state">
<p>No hay cuentas</p>
<a href="{% url 'account_create' %}">Añade la primera</a>
</td>

View File

@ -43,6 +43,7 @@
<a href="{% url 'category_list' %}">Categorías</a>
<a href="{% url 'tag_list' %}">Etiquetas</a>
<a href="{% url 'goal_list' %}">Objetivos</a>
<a href="{% url 'budget_blocks' %}">Reparto por bloques</a>
<a href="{% url 'export_index' %}">Exportar datos</a>
</div>
</div>

View File

@ -0,0 +1,76 @@
{% extends "expenses/base.html" %}
{% block title %}Reparto por bloques{% endblock %}
{% block content %}
<h1>Reparto por bloques</h1>
<p>Qué porcentaje de tus ingresos debería ir a cada bloque. Los bloques de gasto son los tipos que asignas a tus categorías; el ahorro son los traspasos a tus cuentas de ahorro.</p>
{# Arranca con el reparto guardado; el script de abajo lo recalcula al teclear. #}
<p id="budgetTotals" aria-live="polite">Asignado: {{ assigned|floatformat:"-2" }} %. Sin asignar: {{ unassigned|floatformat:"-2" }} %.</p>
{% if proposal %}
<p class="muted">
Junto a cada bloque tienes lo que fue de verdad del {{ proposal.date_from|date:"d/m/Y" }} al {{ proposal.date_to|date:"d/m/Y" }}, sobre tus ingresos de ese periodo.
{% if proposal.unclassified %}El {{ proposal.unclassified }} % se fue en gastos sin tipo, que no cuentan en ningún bloque.{% endif %}
</p>
{% else %}
<p class="muted">No hay ingresos en los últimos 12 meses completos, así que no se puede calcular una propuesta.</p>
{% endif %}
<form method="post" action="{% url 'budget_blocks' %}" class="app-form" id="budgetForm">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Guardar</button>
{% if proposal %}
<a class="btn btn-secondary" href="{% url 'budget_blocks' %}?propuesta=1">Usar valores calculados</a>
{% endif %}
<a class="btn btn-secondary" href="{% url 'settings_index' %}">Volver</a>
</div>
{% if proposal_total > 100 %}
<p class="form-errors">Los valores calculados suman un {{ proposal_total }} %: en ese periodo gastaste más de lo que ingresaste. Puedes cargarlos, pero tendrás que bajar algún bloque hasta que la suma no pase de 100 para poder guardar.</p>
{% endif %}
</form>
<script>
// Suma en vivo de los cinco bloques. Es solo comodidad: quien decide si
// se puede guardar es la validación del servidor.
(function () {
const form = document.getElementById('budgetForm');
const totals = document.getElementById('budgetTotals');
const names = [{% for field in form %}"{{ field.html_name }}",{% endfor %}];
function format(cents) {
return (cents / 100).toLocaleString('es-ES', { maximumFractionDigits: 2 });
}
function update() {
// En céntimos enteros y redondeados: sin el redondeo,
// 18,1 × 100 + 81,9 × 100 da 10000,000000000002 y avisaría
// de que pasa de 100.
let cents = 0;
names.forEach(function (name) {
const value = parseFloat(form.elements[name].value);
if (!isNaN(value)) {
cents += Math.round(value * 100);
}
});
if (cents > 10000) {
totals.textContent = 'Asignado: ' + format(cents) + ' %. Pasa de 100 por '
+ format(cents - 10000) + ' %: así no se podrá guardar.';
totals.className = 'form-errors';
} else {
totals.textContent = 'Asignado: ' + format(cents) + ' %. Sin asignar: '
+ format(10000 - cents) + ' %.';
totals.className = '';
}
}
form.addEventListener('input', update);
})();
</script>
{% endblock %}

View File

@ -25,6 +25,11 @@
<p>Define y controla tus metas de gasto.</p>
</a>
<a class="settings-card" href="{% url 'budget_blocks' %}">
<h2>Reparto por bloques</h2>
<p>Decide qué parte de tus ingresos va a cada tipo de gasto y al ahorro.</p>
</a>
<a class="settings-card" href="{% url 'export_index' %}">
<h2>Exportar datos</h2>
<p>Descarga tus gastos, ingresos o traspasos en CSV.</p>

View File

@ -0,0 +1,317 @@
import pytest
from datetime import date, timedelta
from decimal import Decimal
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from expenses.forms import BudgetBlockForm
from expenses.models import Account, BudgetBlock, Category, Expense, Income, Transfer
from expenses.views import _budget_block_proposal, _savings_total
pytestmark = pytest.mark.django_db
# Con este "hoy" la propuesta mira del 01/07/2023 al 30/06/2024.
TODAY = date(2024, 7, 15)
WINDOW = (date(2023, 7, 1), date(2024, 7, 1))
def _account(user, name, is_savings=False):
return Account.objects.create(
owner=user, name=name, initial_balance=Decimal("0"), active=True,
is_savings=is_savings,
)
def _transfer(user, from_account, to_account, amount, on=date(2024, 3, 10)):
transfer = Transfer(
owner=user, date=on, amount=Decimal(amount),
from_account=from_account, to_account=to_account,
)
transfer.sync()
return transfer
def _expense(user, account, category, amount, on=date(2024, 3, 1)):
return Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal(amount), date=on,
)
def _block_data(**values):
data = {block: "0" for block, _ in BudgetBlock.BLOCK_CHOICES}
data.update(values)
return data
# --------------------------------------------------------------------------
# Ahorro: los tres casos de traspaso
# --------------------------------------------------------------------------
def test_transfer_into_a_savings_account_counts_as_saving(user):
_transfer(user, _account(user, "Nómina"), _account(user, "Hucha", is_savings=True), "300")
assert _savings_total(user, *WINDOW) == Decimal("300")
def test_transfer_out_of_a_savings_account_subtracts(user):
checking = _account(user, "Nómina")
savings = _account(user, "Hucha", is_savings=True)
_transfer(user, checking, savings, "300")
_transfer(user, savings, checking, "120")
# Sacar dinero del colchón es desahorro, no ahorro.
assert _savings_total(user, *WINDOW) == Decimal("180")
def test_transfer_between_two_savings_accounts_does_not_count(user):
piggy = _account(user, "Hucha", is_savings=True)
deposit = _account(user, "Depósito", is_savings=True)
_transfer(user, piggy, deposit, "1000")
_transfer(user, deposit, piggy, "250")
# Mover dinero de una hucha a otra no es ahorro nuevo, en ningún sentido.
assert _savings_total(user, *WINDOW) == Decimal("0")
# --------------------------------------------------------------------------
# Validación de la suma
# --------------------------------------------------------------------------
def test_form_accepts_exactly_100(user):
form = BudgetBlockForm(_block_data(fixed_essential="50", saving="50"), user=user)
assert form.is_valid(), form.errors
def test_form_rejects_more_than_100(user):
form = BudgetBlockForm(_block_data(fixed_essential="51", saving="50"), user=user)
assert not form.is_valid()
assert "no pueden pasar del 100" in str(form.non_field_errors())
def test_saving_80_leaves_20_unassigned(auth_client, user):
response = auth_client.post(
reverse("budget_blocks"),
_block_data(fixed_essential="50", variable_essential="20", saving="10"),
follow=True,
)
assert BudgetBlock.objects.filter(owner=user).count() == 5
assert response.context["assigned"] == Decimal("80")
assert response.context["unassigned"] == Decimal("20")
assert "Sin asignar: 20 %" in response.content.decode()
def test_blocks_without_rows_are_zero_and_all_five_are_shown(auth_client, user):
BudgetBlock.objects.create(owner=user, block="saving", percentage=Decimal("15"))
response = auth_client.get(reverse("budget_blocks"))
form = response.context["form"]
assert list(form.fields) == [block for block, _ in BudgetBlock.BLOCK_CHOICES]
assert form.initial == {
"fixed_essential": Decimal("0"),
"fixed_discretionary": Decimal("0"),
"variable_essential": Decimal("0"),
"variable_discretionary": Decimal("0"),
"saving": Decimal("15"),
}
content = response.content.decode()
for block, _ in BudgetBlock.BLOCK_CHOICES:
assert 'name="%s"' % block in content
# --------------------------------------------------------------------------
# Propuesta a partir de los datos reales
# --------------------------------------------------------------------------
def _known_data(user):
checking = _account(user, "Nómina")
savings = _account(user, "Hucha", is_savings=True)
Income.objects.create(
owner=user, account=checking, name="Nómina",
amount=Decimal("2000"), date=date(2024, 3, 5),
)
home = Category.objects.create(
name="Casa", owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
# Hija sin tipo propio: hereda "Fijo esencial" de Casa.
power = Category.objects.create(name="Luz", owner=user, parent=home)
fun = Category.objects.create(
name="Ocio", owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY
)
loose = Category.objects.create(name="Varios", owner=user)
_expense(user, checking, home, "500")
_expense(user, checking, power, "100")
_expense(user, checking, fun, "200")
_expense(user, checking, loose, "100")
_transfer(user, checking, savings, "300")
# Fuera de la ventana de 12 meses completos: no cuentan.
_expense(user, checking, home, "999", on=date(2024, 7, 2))
_expense(user, checking, home, "999", on=date(2023, 6, 30))
return checking, savings
def test_proposal_percentages_with_known_data(user):
_known_data(user)
proposal = _budget_block_proposal(user, TODAY)
assert proposal["date_from"] == date(2023, 7, 1)
assert proposal["date_to"] == date(2024, 6, 30)
assert proposal["blocks"] == {
"fixed_essential": Decimal("30.0"), # (500 + 100) / 2000
"fixed_discretionary": Decimal("0.0"),
"variable_essential": Decimal("0.0"),
"variable_discretionary": Decimal("10.0"), # 200 / 2000
"saving": Decimal("15.0"), # 300 / 2000
}
assert proposal["unclassified"] == Decimal("5.0")
def test_proposal_uses_inherited_expense_type(user):
checking = _account(user, "Nómina")
Income.objects.create(
owner=user, account=checking, name="Nómina",
amount=Decimal("1000"), date=date(2024, 3, 5),
)
parent = Category.objects.create(
name="Coche", owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_ESSENTIAL
)
child = Category.objects.create(name="Gasolina", owner=user, parent=parent)
_expense(user, checking, child, "80")
proposal = _budget_block_proposal(user, TODAY)
assert proposal["blocks"]["variable_essential"] == Decimal("8.0")
assert proposal["unclassified"] == Decimal("0.0")
def test_proposal_is_none_without_income(user):
assert _budget_block_proposal(user, TODAY) is None
def test_proposal_query_count_does_not_grow(user):
checking, _ = _known_data(user)
with CaptureQueriesContext(connection) as small:
_budget_block_proposal(user, TODAY)
parent = Category.objects.get(name="Casa")
for i in range(10):
parent = Category.objects.create(name=f"Nivel {i}", owner=user, parent=parent)
_expense(user, checking, parent, "1")
with CaptureQueriesContext(connection) as large:
_budget_block_proposal(user, TODAY)
assert len(large.captured_queries) == len(small.captured_queries) == 4
def test_use_proposal_prefills_without_saving(auth_client, user):
# La vista usa el "hoy" real: los datos van al mes pasado, que siempre cae
# dentro de los 12 meses completos.
last_month = date.today().replace(day=1) - timedelta(days=1)
checking = _account(user, "Nómina")
Income.objects.create(
owner=user, account=checking, name="Nómina",
amount=Decimal("1000"), date=last_month,
)
_transfer(user, checking, _account(user, "Hucha", is_savings=True), "250", on=last_month)
response = auth_client.get(reverse("budget_blocks"), {"propuesta": "1"})
form = response.context["form"]
assert form.initial["saving"] == Decimal("25.0")
# Con coma decimal, como el resto de números de la interfaz.
assert "Calculado: 25,0 %" in form.fields["saving"].help_text
assert not BudgetBlock.objects.filter(owner=user).exists()
def _last_month_income_and_expense(user, income, expense):
# La vista usa el "hoy" real: el mes pasado siempre cae dentro de los 12
# meses completos de la propuesta.
last_month = date.today().replace(day=1) - timedelta(days=1)
checking = _account(user, "Nómina")
Income.objects.create(
owner=user, account=checking, name="Nómina",
amount=Decimal(income), date=last_month,
)
rent = Category.objects.create(
name="Alquiler", owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
_expense(user, checking, rent, expense, on=last_month)
def test_proposal_over_100_is_flagged_next_to_the_button(auth_client, user):
# Más gasto que ingreso: Fijo esencial sale al 120 %.
_last_month_income_and_expense(user, income="1000", expense="1200")
response = auth_client.get(reverse("budget_blocks"))
assert response.context["proposal_total"] == Decimal("120.0")
assert "suman un 120,0 %" in response.content.decode()
def test_proposal_up_to_100_has_no_warning(auth_client, user):
_last_month_income_and_expense(user, income="1000", expense="600")
response = auth_client.get(reverse("budget_blocks"))
assert response.context["proposal_total"] == Decimal("60.0")
assert "suman un" not in response.content.decode()
# --------------------------------------------------------------------------
# Aislamiento y login
# --------------------------------------------------------------------------
def test_budget_of_another_user_is_neither_shown_nor_edited(auth_client, user, django_user_model):
other = django_user_model.objects.create_user(username="otro", password="x-pass-123")
BudgetBlock.objects.create(owner=other, block="saving", percentage=Decimal("40"))
response = auth_client.get(reverse("budget_blocks"))
assert response.context["form"].initial["saving"] == Decimal("0")
assert response.context["assigned"] == Decimal("0")
auth_client.post(reverse("budget_blocks"), _block_data(saving="25"))
assert BudgetBlock.objects.get(owner=other, block="saving").percentage == Decimal("40")
assert BudgetBlock.objects.get(owner=user, block="saving").percentage == Decimal("25")
def test_budget_screen_requires_login(client):
response = client.get(reverse("budget_blocks"))
assert response.status_code == 302
assert reverse("login") in response["Location"]
def test_budget_screen_is_linked_from_hub_and_menu(auth_client):
# Son dos sitios independientes: el hub de Configuración y el desplegable
# del menú. Con la exportación se puso solo uno y desde el menú no se
# llegaba.
hub = auth_client.get(reverse("settings_index")).content.decode()
menu = auth_client.get(reverse("home")).content.decode()
link = 'href="%s"' % reverse("budget_blocks")
assert link in hub
assert link in menu
# --------------------------------------------------------------------------
# Campo de cuenta de ahorro
# --------------------------------------------------------------------------
def test_account_form_saves_savings_flag_and_list_shows_it(auth_client, user):
auth_client.post(
reverse("account_create"),
{"name": "Hucha", "initial_balance": "0", "active": "on", "is_savings": "on"},
)
assert Account.objects.get(owner=user, name="Hucha").is_savings is True
assert "De ahorro" in auth_client.get(reverse("account_list")).content.decode()

View File

@ -33,6 +33,7 @@ urlpatterns = [
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
path('settings/', views.settings_index, name='settings_index'),
path('budget/', views.budget_blocks, name='budget_blocks'),
path('export/', views.export_index, name='export_index'),
path('export/download/', views.export_download, name='export_download'),
path('goals/', views.goal_list, name='goal_list'),

View File

@ -13,6 +13,7 @@ from .models import (
Income,
Goal,
Transfer,
BudgetBlock,
)
from .forms import (
ExpenseForm,
@ -24,11 +25,12 @@ from .forms import (
GoalForm,
TransferForm,
ExportForm,
BudgetBlockForm,
)
from .csv_export import DIALECTS, EXPORTS, csv_filename, stream_csv
from django.core.paginator import Paginator
from django.db.models import Sum, Count, Min, ProtectedError
from django.db.models import Sum, Count, Min, ProtectedError, Q
from django.db.models.functions import (
ExtractMonth,
ExtractYear,
@ -39,6 +41,7 @@ from django.db.models.functions import (
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest, StreamingHttpResponse
from django.utils.formats import number_format
from django.utils.http import url_has_allowed_host_and_scheme
from django.shortcuts import get_object_or_404, render, redirect
from django.urls import reverse
@ -185,6 +188,75 @@ def _expense_type_breakdown(user, expenses):
return rows, classified_count
def _savings_total(user, date_from, date_to):
"""Ahorro neto del periodo, medido por los traspasos entre cuentas.
No todos los traspasos cuentan igual:
- de una cuenta normal a una de ahorro: suma, es ahorrar;
- de una de ahorro a una normal: resta, es desahorro, y sacar dinero del
colchón no puede contar como ahorrar;
- entre dos de ahorro: ni suma ni resta. Mover dinero de una hucha a otra
no es ahorro nuevo, y contarlo inflaría el bloque cada vez que se
reorganizan cuentas.
Solo cuenta el importe del traspaso; la comisión ya es un gasto normal.
Una sola consulta. `date_to` es exclusivo."""
totals = Transfer.objects.filter(
owner=user, date__gte=date_from, date__lt=date_to
).aggregate(
saved=Sum(
"amount",
filter=Q(to_account__is_savings=True, from_account__is_savings=False),
),
withdrawn=Sum(
"amount",
filter=Q(from_account__is_savings=True, to_account__is_savings=False),
),
)
return (totals["saved"] or Decimal("0")) - (totals["withdrawn"] or Decimal("0"))
def _budget_block_proposal(user, today):
"""Reparto propuesto a partir de los últimos 12 meses completos.
Cada bloque es su parte del ingreso real del periodo, sin traspasos. Como
es la misma división para los dos lados, el bloque sobre el ingreso medio
mensual es lo mismo que el bloque sobre el ingreso total, así que da igual
si hay 12 meses de datos o menos.
Devuelve None si no hubo ingresos: sin denominador no hay porcentaje.
Son cuatro consultas fijas (dos de _expense_type_breakdown, ingresos y
traspasos), crezca lo que crezca el número de categorías o de gastos."""
date_to = today.replace(day=1)
date_from = date(*sub_months(date_to.year, date_to.month, 12), 1)
income = _analysis_incomes(user).filter(
date__gte=date_from, date__lt=date_to
).aggregate(total=Sum("amount"))["total"]
if not income:
return None
expenses = _analysis_expenses(user).filter(date__gte=date_from, date__lt=date_to)
breakdown, _ = _expense_type_breakdown(user, expenses)
totals = {row["expense_type"]: row["total"] for row in breakdown}
totals[BudgetBlock.BLOCK_SAVING] = _savings_total(user, date_from, date_to)
def pct(amount):
# Un desahorro neto daría un porcentaje negativo, que el formulario no
# admite: la propuesta se queda en 0 para ese bloque.
return max(amount / income * 100, Decimal("0")).quantize(Decimal("0.1"))
return {
"date_from": date_from,
# El último día incluido, para enseñarlo en la pantalla.
"date_to": date_to - timedelta(days=1),
"blocks": {block: pct(totals[block]) for block, _ in BudgetBlock.BLOCK_CHOICES},
# El gasto sin clasificar no va a ningún bloque. Se enseña aparte para
# que se entienda por qué la propuesta no llega al 100 %.
"unclassified": pct(totals[""]),
}
def _without_transfers(movements):
"""Quita las patas de traspaso de un queryset de gastos o de ingresos.
@ -1782,6 +1854,54 @@ def settings_index(request):
return render(request, "settings/index.html", {"active_menu": "settings"})
@login_required
def budget_blocks(request):
proposal = _budget_block_proposal(request.user, date.today())
if request.method == "POST":
form = BudgetBlockForm(request.POST, user=request.user)
if form.is_valid():
form.save()
messages.success(request, "Reparto guardado.")
return redirect("budget_blocks")
elif proposal and request.GET.get("propuesta"):
# "Usar valores calculados" solo precarga el formulario: no se guarda
# nada hasta que el usuario pulsa Guardar.
form = BudgetBlockForm(user=request.user, initial=proposal["blocks"])
else:
form = BudgetBlockForm(user=request.user)
proposal_total = None
if proposal:
for block, value in proposal["blocks"].items():
# number_format para que salga con coma decimal, como en las
# plantillas; con %s quedaría "25.0" al lado de "25,0".
form.fields[block].help_text = "Calculado: %s %%" % number_format(value)
# Con más gasto que ingreso en el periodo la propuesta pasa de 100 y,
# aplicada tal cual, la rechazaría la validación al guardar: la
# plantilla lo avisa junto al botón.
proposal_total = sum(proposal["blocks"].values())
# Lo que queda sin asignar con el reparto guardado, no con lo que se esté
# tecleando: es lo que vale de verdad mientras no se guarde.
assigned = BudgetBlock.objects.filter(owner=request.user).aggregate(
total=Sum("percentage")
)["total"] or Decimal("0")
return render(
request,
"settings/budget_blocks.html",
{
"active_menu": "settings",
"form": form,
"proposal": proposal,
"proposal_total": proposal_total,
"assigned": assigned,
"unassigned": Decimal("100") - assigned,
},
)
@login_required
def transfer_list(request):
transfers = Transfer.objects.filter(owner=request.user).select_related(