Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion modules/tax_management/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,38 @@
Handles tax forms (Modelo 349, 303, 130, 390, 100) and Social Security payments.
"""

import csv
import io

from module_manager import BaseModule
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response
from datetime import datetime


def _csv_safe(value):
"""Neutralize spreadsheet formula injection in user-entered text."""
if value and value[0] in ('=', '+', '-', '@', '\t', '\r'):
return "'" + value
return value


def build_ss_payments_csv(payments):
"""Render SS payments to CSV text (Date, Description, Amount) with a total row."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(['Date', 'Description', 'Amount (EUR)'])
total = 0.0
for p in payments:
writer.writerow([
p.payment_date.isoformat(),
_csv_safe(p.description or ''),
f'{p.amount:.2f}',
])
total += p.amount
writer.writerow(['', 'Total', f'{total:.2f}'])
return buf.getvalue()


class TaxManagementModule(BaseModule):
"""Tax forms and Social Security payment management"""

Expand Down Expand Up @@ -133,6 +160,11 @@ def ss_payment_edit(id):
def ss_payment_delete(id):
return self._delete_ss_payment(id)

@bp.route('/ss-payments/export')
@login_required
def ss_payment_export():
return self._export_ss_payments()

app.register_blueprint(bp)

# --- REST API (served under /api/v1/m/tax_management/... ) ---
Expand Down Expand Up @@ -352,6 +384,28 @@ def _edit_ss_payment(self, id):
flash('Error processing form data. Please check your input.', 'danger')
return redirect(url_for('tax_management.tax_forms_index'))

def _export_ss_payments(self):
"""Export SS payments as a CSV download, optionally filtered by ?year=YYYY"""
year = request.args.get('year', type=int)
query = self.SSPayment.query
if year:
query = query.filter(
self._db.extract('year', self.SSPayment.payment_date) == year
)
payments = query.order_by(self.SSPayment.payment_date).all()

csv_text = build_ss_payments_csv(payments)
filename = f'ss_payments_{year}.csv' if year else 'ss_payments.csv'
self.core.log_activity(
'ss_payments_exported', 'tax',
f'{len(payments)} payments' + (f', year {year}' if year else '')
)
return Response(
csv_text,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
)

def _delete_ss_payment(self, id):
"""Delete a Social Security payment"""
try:
Expand Down
9 changes: 8 additions & 1 deletion modules/tax_management/templates/tax_forms.html
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,12 @@ <h3 style="margin: 0; font-size: 16px; color: var(--color-text);">Uploaded Tax F

<!-- Social Security Payments -->
<div id="ss-payments" style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 4px; margin-top: 30px; overflow: hidden;">
<div style="background: var(--color-success-bg); padding: 15px; border-bottom: 1px solid var(--color-border);">
<div style="background: var(--color-success-bg); padding: 15px; border-bottom: 1px solid var(--color-border); display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0; font-size: 16px; color: var(--color-success-fg);">Social Security Payments (Cuotas Autónomo)</h3>
{% if ss_by_year %}
<a href="{{ url_for('tax_management.ss_payment_export') }}" class="btn btn-secondary"
style="padding: 4px 10px; font-size: 12px;" title="Download all SS payments as CSV">⬇ Export CSV</a>
{% endif %}
</div>
<div style="padding: 20px;">
<!-- Add SS Payment Form -->
Expand Down Expand Up @@ -199,6 +203,9 @@ <h3 style="margin: 0; font-size: 16px; color: var(--color-success-fg);">Social S
</td>
<td style="text-align: right; padding: 10px 12px; font-weight: bold; font-size: 14px; color: var(--color-primary);">
€{{ "%.2f"|format(ss_by_year[year]|sum(attribute='amount')) }}
<a href="{{ url_for('tax_management.ss_payment_export', year=year) }}"
style="font-size: 11px; font-weight: normal; margin-left: 8px;"
title="Download {{ year }} SS payments as CSV">CSV</a>
</td>
</tr>
{% for p in ss_by_year[year] %}
Expand Down
57 changes: 57 additions & 0 deletions tests/test_tax_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Unit tests for the tax_management module's CSV export helpers."""
from datetime import date
from types import SimpleNamespace

from modules.tax_management.index import build_ss_payments_csv, _csv_safe


def _payment(payment_date, amount, description=None):
return SimpleNamespace(
payment_date=payment_date, amount=amount, description=description
)


def test_csv_has_header_rows_and_total():
payments = [
_payment(date(2026, 1, 31), 299.57, 'SS Jan 2026'),
_payment(date(2026, 2, 28), 299.57, 'SS Feb 2026'),
]
lines = build_ss_payments_csv(payments).strip().splitlines()
assert lines[0] == 'Date,Description,Amount (EUR)'
assert lines[1] == '2026-01-31,SS Jan 2026,299.57'
assert lines[2] == '2026-02-28,SS Feb 2026,299.57'
assert lines[3] == ',Total,599.14'


def test_csv_empty_payments_still_has_header_and_zero_total():
lines = build_ss_payments_csv([]).strip().splitlines()
assert lines[0] == 'Date,Description,Amount (EUR)'
assert lines[1] == ',Total,0.00'


def test_csv_handles_missing_description():
payments = [_payment(date(2026, 3, 31), 100.0)]
lines = build_ss_payments_csv(payments).strip().splitlines()
assert lines[1] == '2026-03-31,,100.00'


def test_csv_quotes_descriptions_with_commas():
payments = [_payment(date(2026, 4, 30), 50.0, 'Regularización, 2024')]
lines = build_ss_payments_csv(payments).strip().splitlines()
assert lines[1] == '2026-04-30,"Regularización, 2024",50.00'


def test_csv_safe_neutralizes_formula_injection():
assert _csv_safe('=SUM(A1:A9)') == "'=SUM(A1:A9)"
assert _csv_safe('+1234') == "'+1234"
assert _csv_safe('-1234') == "'-1234"
assert _csv_safe('@cmd') == "'@cmd"
assert _csv_safe('normal text') == 'normal text'
assert _csv_safe('') == ''
assert _csv_safe(None) is None


def test_csv_formula_injection_in_description_is_escaped():
payments = [_payment(date(2026, 5, 31), 10.0, '=HYPERLINK("http://evil")')]
out = build_ss_payments_csv(payments)
assert '"\'=HYPERLINK(""http://evil"")"' in out
Loading