Compare commits

...

6 Commits

Author SHA1 Message Date
8e408797ec Merge pull request 'Repartos' (#44) from dev into main
All checks were successful
expenses_manager/pipeline/head This commit looks good
Tests / CI / Run tests passed: 370
Reviewed-on: #44
2026-10-21 16:45:06 +00:00
41831990f0 Merge branch 'main' into dev
All checks were successful
Tests / CI / Run tests passed: 370
expenses_manager/pipeline/head This commit looks good
2026-10-21 16:44:30 +00:00
aacbe3b8c2 Merge branches 'dev' and 'dev' of https://gitea.kuijper.es/jkuijperm/expenses_manager into dev
All checks were successful
Tests / CI / Run tests passed: 370
expenses_manager/pipeline/head This commit looks good
2026-10-21 18:41:09 +02:00
d9b0ce4287 Add budget block allocation screen 2026-10-21 18:41:05 +02:00
e64421b3df Add savings account flag and budget block model 2026-10-21 18:41:01 +02:00
40b0c7ed7b Add expense type breakdown chart to dashboard 2026-10-18 15:59:35 +02:00
14 changed files with 1115 additions and 19 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

@ -0,0 +1,34 @@
# Generated by Django 5.2.10 on 2026-10-18 14:51
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0018_category_expense_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='account',
name='is_savings',
field=models.BooleanField(default=False, help_text='Márcala si es una cuenta donde guardas dinero: los traspasos que entran en ella cuentan como ahorro en el reparto por bloques.', verbose_name='Cuenta de ahorro'),
),
migrations.CreateModel(
name='BudgetBlock',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('block', models.CharField(choices=[('fixed_essential', 'Fijo esencial'), ('fixed_discretionary', 'Fijo prescindible'), ('variable_essential', 'Variable esencial'), ('variable_discretionary', 'Variable prescindible'), ('saving', 'Ahorro')], max_length=30)),
('percentage', models.DecimalField(decimal_places=2, default=0, max_digits=5, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budget_blocks', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('owner', 'block')},
},
),
]

View File

@ -1,6 +1,7 @@
from datetime import date
from decimal import Decimal
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.conf import settings
from django.db.models import Q, Sum
@ -114,6 +115,14 @@ class Account(models.Model):
name = models.CharField(max_length=100)
initial_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0)
active = models.BooleanField(default=True)
is_savings = models.BooleanField(
default=False,
verbose_name="Cuenta de ahorro",
help_text=(
"Márcala si es una cuenta donde guardas dinero: los traspasos que "
"entran en ella cuentan como ahorro en el reparto por bloques."
),
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@ -823,3 +832,35 @@ class Goal(models.Model):
def __str__(self):
return self.name
class BudgetBlock(models.Model):
"""Qué porcentaje del ingreso debería ir a un bloque.
Los bloques son los cuatro tipos de gasto de Category más el ahorro: lo que
el usuario clasificó en sus categorías es lo que ve aquí, sin un tercer eje.
Un único reparto por usuario, sin versionar por fecha: un histórico obliga
a decidir qué reparto aplica a cada mes, y eso se añade el día que haga
falta. Un bloque sin fila vale 0.
"""
BLOCK_SAVING = "saving"
BLOCK_CHOICES = Category.EXPENSE_TYPE_CHOICES + [(BLOCK_SAVING, "Ahorro")]
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="budget_blocks"
)
block = models.CharField(max_length=30, choices=BLOCK_CHOICES)
percentage = models.DecimalField(
max_digits=5,
decimal_places=2,
default=0,
validators=[MinValueValidator(0), MaxValueValidator(100)],
)
class Meta:
unique_together = ("owner", "block")
def __str__(self):
return f"{self.get_block_display()}: {self.percentage} %"

View File

@ -561,36 +561,85 @@ button[type="submit"]:hover,
}
/* Cabecera común de las tarjetas de gráfico: el título a la izquierda y, si lo
lleva, su control a la derecha. El min-height reserva el alto del selector
también en las tarjetas que no lo tienen, así que los dos ejes arrancan a la
misma altura por construcción y no por casualidad. Por eso no se compensa
tocando el alto del canvas: eso cuadraría solo a un ancho concreto.
lleva, su control a la derecha. Cuando el control no cabe junto al título,
baja a una segunda línea. Eso pasa a anchos de escritorio normales: el de
tipo de gasto lleva dos grupos de controles y una tarjeta mide ~340 px con
cuatro por fila. El min-height reserva esas dos líneas (título de 2.25rem,
0.5rem de separación y controles de 2.25rem) en todas las tarjetas, tengan
controles o no. Así los canvas de una misma fila arrancan a la misma altura
por construcción y no por casualidad. Por eso no se compensa tocando el alto
del canvas: eso cuadraría solo a un ancho concreto.
Los márgenes son los que traía el h2, para no mover el ritmo vertical. */
.chart-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 1rem;
min-height: 2.5rem;
align-content: flex-start;
gap: 0.5rem 1rem;
min-height: 5rem;
margin: 1.5rem 0 0.75rem;
}
/* Sin esto el margen del h2 se sumaría al de la cabecera y el min-height
dejaría de mandar. */
dejaría de mandar. El alto mínimo iguala la línea del título a la de los
controles: haya o no un control al lado, el título queda a la misma altura
en todas las tarjetas. */
.chart-header h2 {
margin: 0;
min-height: 2.25rem;
display: flex;
align-items: center;
}
/* El alto del control se fija por debajo del min-height de la cabecera. Así la
cabecera mide siempre el min-height en las dos tarjetas y la alineación no
depende de cuánto mida un <select> en cada navegador, que es justo lo que
haría que cuadrara en uno y no en otro. */
/* El alto del control se fija igual que la línea del título. Así la cabecera
mide siempre el min-height en todas las tarjetas y la alineación no depende
de cuánto mida un <select> en cada navegador, que es justo lo que haría que
cuadrara en uno y no en otro. */
.chart-header select {
height: 2.25rem;
padding-top: 0;
padding-bottom: 0;
}
/* Grupo de botones de icono que se excluyen entre sí (la forma del gráfico).
Mismo alto que el <select> de al lado; el pulsado lo marca aria-pressed. */
.icon-toggle {
display: inline-flex;
box-sizing: border-box;
height: 2.25rem;
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
}
.icon-toggle button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 100%;
padding: 0;
border: none;
color: var(--color-text-muted);
background: var(--color-surface);
cursor: pointer;
}
.icon-toggle button + button {
border-left: 1px solid var(--color-border);
}
.icon-toggle button[aria-pressed="true"] {
color: var(--color-on-primary);
background: var(--color-primary);
}
.icon-toggle button:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
}
.chart-box {
position: relative;
height: 320px;

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

@ -235,7 +235,60 @@
</section>
<section>
<h2>Gastos Recientes</h2>
<div class="chart-header">
<h2>Distribución por tipo de gasto</h2>
{% if expense_type_classified_count >= 2 and kpi_total %}
<div class="filters-main">
<label class="sr-only" for="expenseTypeGrouping">Agrupación</label>
<select id="expenseTypeGrouping">
<option value="types">Cuatro tipos</option>
<option value="fixed">Fijo / variable</option>
<option value="essential">Esencial / prescindible</option>
</select>
<div class="icon-toggle" role="group" aria-label="Forma del gráfico">
<button type="button" data-shape="doughnut" aria-label="Donut" aria-pressed="true">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" stroke-width="4"/>
</svg>
</button>
<button type="button" data-shape="bar" aria-label="Barras horizontales" aria-pressed="false">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
<rect x="3" y="4" width="16" height="4" fill="currentColor"/>
<rect x="3" y="10" width="10" height="4" fill="currentColor"/>
<rect x="3" y="16" width="6" height="4" fill="currentColor"/>
</svg>
</button>
<button type="button" data-shape="column" aria-label="Barras verticales" aria-pressed="false">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
<rect x="4" y="5" width="4" height="16" fill="currentColor"/>
<rect x="10" y="11" width="4" height="10" fill="currentColor"/>
<rect x="16" y="15" width="4" height="6" fill="currentColor"/>
</svg>
</button>
</div>
</div>
{% endif %}
</div>
{% if expense_type_classified_count < 2 %}
<div class="empty-state">
<p>Clasifica al menos dos categorías para ver este desglose</p>
<a href="{% url 'category_list' %}">Ir a categorías</a>
</div>
{% elif kpi_total %}
<div class="chart-box">
<canvas id="expenseTypeChart"></canvas>
</div>
{% else %}
<p class="empty-state">No hay gastos en este periodo</p>
{% endif %}
</section>
</div>
<!-- Fuera de .charts-container: es una tabla, no tiene eje que alinear con
los gráficos y encajada entre dos canvas había que desplazarla para
ver sus columnas. -->
<section>
<h2>Gastos Recientes</h2>
<div class="table-wrap">
<table class="table">
<thead>
@ -267,9 +320,8 @@
</tbody>
</table>
</div>
</section>
</div>
</section>
<div style="clear: both; margin-top: 2rem;"></div>
<script>
@ -302,6 +354,119 @@
registerThemedChart(categoryChart, function (chart, colors) {
chart.options.plugins.legend.labels.color = colors.tickColor;
});
// Sin gasto en el periodo, o con menos de dos categorías clasificadas,
// la plantilla no pinta el canvas.
const expenseTypeCanvas = document.getElementById('expenseTypeChart');
if (expenseTypeCanvas) {
// Orden fijo del servidor: fijo esencial, fijo prescindible,
// variable esencial, variable prescindible y "Sin clasificar".
const typeLabels = [{% for row in expense_type_breakdown %}"{{ row.label }}",{% endfor %}];
const typeTotals = [{% for row in expense_type_breakdown %}{{ row.total|unlocalize }},{% endfor %}];
const t = typeTotals;
// Las agrupaciones de dos bloques se suman aquí con los cinco
// importes que ya llegan: cambiar de vista no pide nada al servidor.
// "Sin clasificar" se queda siempre aparte, nunca se reparte.
const GROUPINGS = {
types: { labels: typeLabels, data: typeTotals },
fixed: { labels: ['Fijo', 'Variable', typeLabels[4]], data: [t[0] + t[1], t[2] + t[3], t[4]] },
essential: { labels: ['Esencial', 'Prescindible', typeLabels[4]], data: [t[0] + t[2], t[1] + t[3], t[4]] }
};
const groupingSelect = document.getElementById('expenseTypeGrouping');
const shapeButtons = document.querySelectorAll('[data-shape]');
let grouping = 'types';
let shape = 'doughnut';
let expenseTypeChart = null;
// Un color por bloque y "Sin clasificar", que va el último, en gris.
function blockColors(count, colors) {
return colors.palette.slice(0, count - 1).concat(colors.tickColor);
}
function applyExpenseTypeColors(chart, colors) {
chart.options.plugins.legend.labels.color = colors.tickColor;
chart.data.datasets[0].backgroundColor = blockColors(chart.data.labels.length, colors);
if (shape !== 'doughnut') {
applyCartesianColors(chart, colors);
}
}
function expenseTypeConfig(colors) {
const group = GROUPINGS[grouping];
const legend = { labels: { color: colors.tickColor } };
const axis = {
grid: { color: colors.gridColor },
ticks: { color: colors.tickColor }
};
if (shape === 'doughnut') {
legend.position = 'right';
return {
type: 'doughnut',
data: {
labels: group.labels,
datasets: [{ data: group.data, backgroundColor: blockColors(group.labels.length, colors) }]
},
options: { plugins: { legend: legend } }
};
}
// Barras horizontales ('bar') o verticales ('column'), con los
// importes en el eje de valores. Cada barra lleva su etiqueta,
// así que la leyenda sobra.
const valueAxis = shape === 'bar' ? 'x' : 'y';
const categoryAxis = shape === 'bar' ? 'y' : 'x';
const scales = {};
scales[valueAxis] = Object.assign({ beginAtZero: true }, axis);
scales[categoryAxis] = axis;
legend.display = false;
return {
type: 'bar',
data: {
labels: group.labels,
datasets: [{ data: group.data, backgroundColor: blockColors(group.labels.length, colors) }]
},
options: {
indexAxis: categoryAxis,
scales: scales,
plugins: { legend: legend }
}
};
}
function renderExpenseTypeChart() {
const config = expenseTypeConfig(getChartColors());
config.options.responsive = true;
config.options.maintainAspectRatio = false;
// Cambiar de forma cambia el tipo de gráfico: hay que destruir y
// crear de nuevo, igual que en el de ingresos vs gastos.
if (expenseTypeChart) {
expenseTypeChart.destroy();
}
expenseTypeChart = new Chart(expenseTypeCanvas, config);
registerThemedChart(expenseTypeChart, applyExpenseTypeColors);
}
groupingSelect.addEventListener('change', function () {
grouping = groupingSelect.value;
renderExpenseTypeChart();
});
shapeButtons.forEach(function (button) {
button.addEventListener('click', function () {
shape = button.dataset.shape;
shapeButtons.forEach(function (other) {
other.setAttribute('aria-pressed', other === button ? 'true' : 'false');
});
renderExpenseTypeChart();
});
});
renderExpenseTypeChart();
}
</script>
<h2>Evolución anual por cuenta ({{ selected_year }})</h2>

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

@ -1,6 +1,8 @@
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.models import Account, Expense, Category, Income, Transfer
@ -481,3 +483,184 @@ def test_dashboard_kpi_balance_matches_sum_of_current_balances(auth_client, user
)
assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva
assert response.context['kpi_balance'] == expected
# --------------------------------------------------------------------------
# Desglose por tipo de gasto
# --------------------------------------------------------------------------
def _breakdown(response):
return {row['label']: row['total'] for row in response.context['expense_type_breakdown']}
def test_expense_type_breakdown_groups_by_effective_type(auth_client, user, account):
home = Category.objects.create(
name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
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)
for category, amount in [(home, '500'), (power, '60'), (fun, '40'), (loose, '7')]:
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal(amount), date=date(2024, 3, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
# La luz hereda "Fijo esencial" de Casa; Varios no tiene tipo en ninguna
# parte de la cadena y va a "Sin clasificar" en vez de desaparecer.
assert _breakdown(response) == {
'Fijo esencial': Decimal('560'),
'Fijo prescindible': Decimal('0'),
'Variable esencial': Decimal('0'),
'Variable prescindible': Decimal('40'),
'Sin clasificar': Decimal('7'),
}
assert sum(_breakdown(response).values()) == response.context['kpi_total']
assert 'id="expenseTypeChart"' in response.content.decode()
def test_expense_type_breakdown_follows_the_selected_period(auth_client, user, account, category):
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal('10'), date=date(2024, 3, 1),
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal('99'), date=date(2024, 4, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
assert _breakdown(response)['Sin clasificar'] == Decimal('10')
def test_expense_type_breakdown_ignores_transfers(auth_client, user, account):
other = Account.objects.create(
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
)
_make_transfer(user, account, other, amount="400", fee="1.50")
response = auth_client.get(reverse('dashboard'), {'year': 2024})
# Solo la comisión es gasto de verdad.
assert sum(_breakdown(response).values()) == Decimal('1.50')
def _classify_two_categories(user):
Category.objects.create(
name='Alquiler', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
Category.objects.create(
name='Ocio', owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY
)
def test_expense_type_breakdown_shows_empty_state_without_expenses(auth_client, user):
_classify_two_categories(user)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert sum(_breakdown(response).values()) == 0
content = response.content.decode()
assert 'id="expenseTypeChart"' not in content
assert 'No hay gastos en este periodo' in content
assert 'Clasifica al menos dos categorías' not in content
def test_expense_type_breakdown_asks_to_classify_with_fewer_than_two(
auth_client, user, account
):
# Una sola categoría clasificada; la hija hereda pero no cuenta como
# clasificada, porque el usuario no la ha clasificado él.
parent = Category.objects.create(
name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
child = Category.objects.create(name='Luz', owner=user, parent=parent)
Expense.objects.create(
owner=user, account=account, category=child,
amount=Decimal('60'), date=date(2024, 3, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['expense_type_classified_count'] == 1
content = response.content.decode()
assert 'Clasifica al menos dos categorías' in content
assert 'href="%s"' % reverse('category_list') in content
assert 'id="expenseTypeChart"' not in content
assert 'id="expenseTypeGrouping"' not in content
assert 'No hay gastos en este periodo' not in content
@pytest.mark.parametrize('classified, asks_to_classify', [(1, True), (2, False)])
def test_expense_type_breakdown_classification_threshold(
auth_client, user, account, classified, asks_to_classify
):
types = [Category.EXPENSE_TYPE_FIXED_ESSENTIAL, Category.EXPENSE_TYPE_VARIABLE_ESSENTIAL]
for i in range(classified):
category = Category.objects.create(name=f'Clasificada {i}', owner=user, expense_type=types[i])
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal('10'), date=date(2024, 3, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['expense_type_classified_count'] == classified
content = response.content.decode()
assert ('Clasifica al menos dos categorías' in content) is asks_to_classify
assert ('id="expenseTypeChart"' in content) is not asks_to_classify
def test_expense_type_breakdown_renders_grouping_and_shape_controls(
auth_client, user, account
):
_classify_two_categories(user)
Expense.objects.create(
owner=user, account=account, category=Category.objects.get(name='Ocio'),
amount=Decimal('40'), date=date(2024, 3, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
content = response.content.decode()
assert 'id="expenseTypeGrouping"' in content
assert 'role="group" aria-label="Forma del gráfico"' in content
assert content.count('data-shape=') == 3
assert content.count('aria-pressed="true"') == 1
def _dashboard_queries(client):
with CaptureQueriesContext(connection) as ctx:
client.get(reverse('dashboard'), {'year': 2024})
return len(ctx.captured_queries)
def test_dashboard_query_count_does_not_grow_with_categories_and_expenses(
auth_client, user, account
):
root = Category.objects.create(
name='Raíz', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
Expense.objects.create(
owner=user, account=account, category=root,
amount=Decimal('1'), date=date(2024, 3, 1),
)
baseline = _dashboard_queries(auth_client)
# Más categorías anidadas y más gastos que las 7 filas de recent_expenses:
# ni la herencia del desglose ni el listado reciente deben pedir una
# consulta por fila.
parent = root
for i in range(10):
parent = Category.objects.create(name=f'Nivel {i}', owner=user, parent=parent)
Expense.objects.create(
owner=user, account=account, category=parent,
amount=Decimal('1'), date=date(2024, 3, 1),
)
assert _dashboard_queries(auth_client) == baseline

View File

@ -104,6 +104,8 @@ DASHBOARD_CONTEXT_KEYS = {
"chart_type",
"by_category",
"by_category_chart",
"expense_type_breakdown",
"expense_type_classified_count",
"accounts_charts",
# Filtros de periodo, cuenta y rango libre
"period",

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
@ -144,6 +147,7 @@ def _category_tree(categories):
rows.append({
"category": category,
"depth": depth,
"expense_type": expense_type,
"expense_type_label": labels.get(expense_type, ""),
"expense_type_inherited": not category.expense_type,
})
@ -153,6 +157,106 @@ def _category_tree(categories):
return rows
def _expense_type_breakdown(user, expenses):
"""Suma `expenses` por el tipo de gasto efectivo de su categoría.
Devuelve siempre las cinco filas (los cuatro tipos y "Sin clasificar" al
final), aunque alguna valga cero: así cada tipo conserva su color en el
gráfico. Lo que no tiene tipo, ni propio ni heredado, va a "Sin
clasificar" y nunca se descarta, para que la suma cuadre con el total.
Devuelve también cuántas categorías tienen tipo propio (sin contar las
que lo heredan), para avisar cuando hay demasiado poco clasificado.
Son dos consultas fijas: las categorías, de las que _category_tree saca el
tipo heredado, y los gastos agrupados por categoría."""
tree = _category_tree(Category.objects.filter(owner=user))
effective = {row["category"].id: row["expense_type"] for row in tree}
classified_count = sum(1 for row in tree if row["category"].expense_type)
labels = dict(Category.EXPENSE_TYPE_CHOICES)
labels[""] = "Sin clasificar"
totals = dict.fromkeys(labels, Decimal("0"))
for row in expenses.values("category").annotate(total=Sum("amount")):
totals[effective.get(row["category"], "")] += row["total"]
rows = [
{"expense_type": key, "label": labels[key], "total": total}
for key, total in totals.items()
]
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.
@ -846,6 +950,12 @@ def dashboard(request):
"category__name": cat_name,
"total": total_cat
})
# Sobre el mismo conjunto que los KPIs (periodo, cuenta y sin traspasos),
# así que sus cinco porciones suman kpi_total.
expense_type_breakdown, expense_type_classified_count = _expense_type_breakdown(
request.user, expenses_filtered
)
#Graphic
# El eje se construye una sola vez (claves + etiquetas + agrupación) y las
@ -999,7 +1109,11 @@ def dashboard(request):
goals = Goal.objects.filter(owner=request.user)
# Recent expenses
recent_expenses = Expense.objects.filter(owner=request.user).order_by('-date', '-id')[:7]
recent_expenses = (
Expense.objects.filter(owner=request.user)
.select_related("category", "account")
.order_by('-date', '-id')[:7]
)
# Send the data to the dashboard
return render(
@ -1010,6 +1124,8 @@ def dashboard(request):
"today": today,
"by_category": by_category,
"by_category_chart": by_category_chart,
"expense_type_breakdown": expense_type_breakdown,
"expense_type_classified_count": expense_type_classified_count,
"chart_labels": chart_labels,
"chart_data": chart_totals,
# La otra serie del gráfico de ingresos contra gastos. Se manda una
@ -1738,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(