chore: money-handling + template hygiene (audit items 11-16)

- money filter: format via Decimal(str(...)) instead of float — money
  never passes through binary floating point on its way to the screen
- preview_payslip / worker_lookup_ajax: accumulate totals in Decimal,
  convert to float only at the JsonResponse boundary, so previews are
  bit-identical to what _process_single_payment records
- price_overtime: explicit .quantize(0.01) instead of letting the DB
  engine silently round the 3rd decimal place
- stacked-chart wage rollup: Decimal(str(salary)) — on SQLite dev the
  value arrives as float and Decimal(float) keeps the binary noise
- payroll_dashboard fmt(): en-ZA space-separated thousands like every
  other money helper on the page (was the one comma-format outlier)
- base.html admin gate: '(auth and staff) or superuser' precedence trap
  replaced with 'staff or superuser' — exact same truth table (both
  flags False on AnonymousUser), matches server-side is_admin()
- attendance_log.html: worker day-rates now ship via the house
  json_script pattern instead of |safe-rendering a Python dict repr
  into the script block

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-06-12 18:04:01 +02:00
parent 541b8973c7
commit 921bdb6b73
5 changed files with 57 additions and 19 deletions

View File

@ -404,7 +404,11 @@
{# === WORK LOG PAYROLL MODAL — click handler + safe DOM builder === #}
{# Builds the modal body from JSON via createElement + textContent. #}
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
{# staff-or-superuser matches the server-side is_admin() helper. The old #}
{# "is_authenticated and is_staff or is_superuser" parsed as "(auth AND #}
{# staff) OR superuser" — template `and` binds tighter than `or`. Both #}
{# flags are False on AnonymousUser, so this simpler form is exact. #}
{% if user.is_staff or user.is_superuser %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var modalEl = document.getElementById('workLogPayrollModal');
@ -636,7 +640,11 @@ document.addEventListener('DOMContentLoaded', function() {
{# === WORK LOG PAYROLL MODAL (admin-only) === #}
{# Hidden by default. Any element with data-log-id anywhere in the app #}
{# triggers this modal. Fetches JSON and builds the DOM safely. #}
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
{# staff-or-superuser matches the server-side is_admin() helper. The old #}
{# "is_authenticated and is_staff or is_superuser" parsed as "(auth AND #}
{# staff) OR superuser" — template `and` binds tighter than `or`. Both #}
{# flags are False on AnonymousUser, so this simpler form is exact. #}
{% if user.is_staff or user.is_superuser %}
<div class="modal fade" id="workLogPayrollModal" tabindex="-1" aria-labelledby="workLogPayrollModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">

View File

@ -216,6 +216,10 @@
</div>
<!-- === JavaScript: Team auto-select + Cost estimator === -->
{# Worker day-rates as a safe JSON island (house json_script pattern — #}
{# never |safe-render data into a <script> block). Admin-only, matching #}
{# the cost-estimator block below that consumes it. #}
{% if is_admin %}{{ worker_rates_json|json_script:"workerRatesData" }}{% endif %}
<script>
document.addEventListener('DOMContentLoaded', function() {
@ -241,7 +245,9 @@ document.addEventListener('DOMContentLoaded', function() {
{% if is_admin %}
// === ESTIMATED COST CALCULATOR (Admin Only) ===
const workerRates = {{ worker_rates_json|safe }};
// Rates come from the #workerRatesData json_script island above —
// parsing JSON is XSS-safe regardless of what the data contains.
const workerRates = JSON.parse(document.getElementById('workerRatesData').textContent);
const startDateInput = document.querySelector('[name="date"]');
const endDateInput = document.querySelector('[name="end_date"]');
const satCheckbox = document.querySelector('[name="include_saturday"]');

View File

@ -1413,8 +1413,13 @@ document.addEventListener('DOMContentLoaded', function() {
const workerChartData = JSON.parse(document.getElementById('workerChartJson').textContent);
// === HELPER: Format currency ===
// en-ZA = space-separated thousands (R 1 234.56) — matches the server's
// `money` filter and the formatMoney/formatRand helpers further down.
// The old comma regex was the one outlier on the page.
function fmt(val) {
return 'R ' + parseFloat(val).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return 'R ' + Number(val).toLocaleString('en-ZA', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
}
// === HELPER: Create a table cell with text ===

View File

@ -2,6 +2,8 @@
# Number formatting filters for South African currency display.
# Usage: {% load format_tags %} then {{ value|money }}
from decimal import Decimal, InvalidOperation
from django import template
register = template.Library()
@ -16,10 +18,13 @@ def money(value):
22500 22 500.00
400.0 400.00
-300.00 -300.00
Formats via Decimal (not float) so money values are never coerced
through binary floating point exact at any magnitude.
"""
try:
num = float(value)
except (ValueError, TypeError):
num = Decimal(str(value))
except (InvalidOperation, ValueError, TypeError):
return value
# Python's :, format gives comma separators — swap commas for spaces

View File

@ -3245,7 +3245,11 @@ def payroll_dashboard(request):
key = (row['project_id'], row['month'].year, row['month'].month)
if key not in project_month_wage:
continue
daily = Decimal(salary) / Decimal('20.00')
# Decimal(str(...)) — on SQLite (local dev) .values() returns a
# float here, and Decimal(float) keeps the float's binary noise;
# str() round-trips cleanly on both backends (MySQL already
# returns Decimal, where this is a no-op).
daily = Decimal(str(salary)) / Decimal('20.00')
project_month_wage[key] += daily * row['worker_count']
# --- 4. Per-project × per-month paid-adjustment net (ONE GROUP BY) ---
@ -4189,7 +4193,12 @@ def price_overtime(request):
rate_pct = Decimal(pct)
# Calculate: daily_rate × overtime_fraction × (rate_percentage / 100)
amount = worker.daily_rate * worklog.overtime_amount * (rate_pct / Decimal('100'))
# Quantize to cents explicitly — 875.00 × 0.25 × 1.5 gives
# 328.125, and without this the DB engine rounds the extra
# decimal place silently (engine-dependent half-up/half-even).
amount = (
worker.daily_rate * worklog.overtime_amount * (rate_pct / Decimal('100'))
).quantize(Decimal('0.01'))
if amount > 0:
PayrollAdjustment.objects.create(
@ -4824,7 +4833,10 @@ def preview_payslip(request, worker_id):
unpaid_logs.sort(key=lambda x: x['date'])
log_count = len(unpaid_logs)
log_amount = float(log_count * worker.daily_rate)
# Keep the running totals in Decimal — float() happens only at the
# JsonResponse boundary below, so the preview math is bit-identical
# to what _process_single_payment will actually record.
log_amount = log_count * worker.daily_rate
# Find pending adjustments — include ID and date for split payslip
pending_adjs = worker.adjustments.filter(
@ -4832,10 +4844,10 @@ def preview_payslip(request, worker_id):
).select_related('project')
adjustments_list = []
adj_total = 0.0
adj_total = Decimal('0.00')
for adj in pending_adjs:
sign = '+' if adj.type in ADDITIVE_TYPES else '-'
adj_total += float(adj.amount) if adj.type in ADDITIVE_TYPES else -float(adj.amount)
adj_total += adj.amount if adj.type in ADDITIVE_TYPES else -adj.amount
adjustments_list.append({
'id': adj.id,
# 'type' keeps the raw DB value so any JS that uses it as an
@ -4893,10 +4905,10 @@ def preview_payslip(request, worker_id):
'worker_id_number': worker.id_number,
'day_rate': float(worker.daily_rate),
'days_worked': log_count,
'log_amount': log_amount,
'log_amount': float(log_amount),
'adjustments': adjustments_list,
'adj_total': adj_total,
'net_pay': log_amount + adj_total,
'adj_total': float(adj_total),
'net_pay': float(log_amount + adj_total),
'logs': unpaid_logs,
'active_loans': loans_list,
'pay_period': pay_period,
@ -4929,18 +4941,20 @@ def worker_lookup_ajax(request, worker_id):
if worker.id not in paid_worker_ids:
unpaid_log_count += 1
log_amount = float(unpaid_log_count * worker.daily_rate)
# Decimal accumulation — float() only when the value enters the JSON
# response, so the report card matches the payment engine's numbers.
log_amount = unpaid_log_count * worker.daily_rate
# Net adjustment total: additive types increase pay, deductive types decrease it
pending_adjs = worker.adjustments.filter(payroll_record__isnull=True)
adj_total = 0.0
adj_total = Decimal('0.00')
for adj in pending_adjs:
if adj.type in ADDITIVE_TYPES:
adj_total += float(adj.amount)
adj_total += adj.amount
elif adj.type in DEDUCTIVE_TYPES:
adj_total -= float(adj.amount)
adj_total -= adj.amount
amount_payable = log_amount + adj_total
amount_payable = float(log_amount + adj_total)
# === OUTSTANDING LOANS ===
# Total remaining balance across all active loans and advances