diff --git a/modules/tax_management/index.py b/modules/tax_management/index.py index 1ebc363..8313303 100644 --- a/modules/tax_management/index.py +++ b/modules/tax_management/index.py @@ -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""" @@ -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/... ) --- @@ -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: diff --git a/modules/tax_management/templates/tax_forms.html b/modules/tax_management/templates/tax_forms.html index cc6d9b5..7037cf2 100644 --- a/modules/tax_management/templates/tax_forms.html +++ b/modules/tax_management/templates/tax_forms.html @@ -158,8 +158,12 @@

Uploaded Tax F
-
+

Social Security Payments (Cuotas Autónomo)

+ {% if ss_by_year %} + ⬇ Export CSV + {% endif %}
@@ -199,6 +203,9 @@

Social S €{{ "%.2f"|format(ss_by_year[year]|sum(attribute='amount')) }} + CSV {% for p in ss_by_year[year] %} diff --git a/tests/test_tax_management.py b/tests/test_tax_management.py new file mode 100644 index 0000000..a0eb3f5 --- /dev/null +++ b/tests/test_tax_management.py @@ -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