diff --git a/core/templates/core/pdf/report_pdf.html b/core/templates/core/pdf/report_pdf.html
index 232e97a..d4f4c80 100644
--- a/core/templates/core/pdf/report_pdf.html
+++ b/core/templates/core/pdf/report_pdf.html
@@ -704,11 +704,12 @@
-
Total Paid Out
+
+ Total Paid Out{% if total_paid_filter_caveat %} {% endif %}
+
R {{ total_paid_out|money }}
@@ -388,8 +390,15 @@
| Category | Total |
+ {# Sign-aware rendering: deductive types show as red "-R …";
+ additive types stay in default colour with no sign. Finding 4. #}
{% for item in adjustment_totals %}
- | {{ item.label }} | R {{ item.total|money }} |
+
+ | {{ item.label }} |
+
+ {% if item.is_deductive %}−{% endif %}R {{ item.total|money_abs }}
+ |
+
{% endfor %}
@@ -478,8 +487,10 @@
Worker |
Days |
Total Paid |
- {% for label in active_adj_labels %}
-
{{ label }} |
+ {# Sign-aware headers: deductive types render in muted red
+ so the negative sign on rows below is unmistakable. Finding 4. #}
+ {% for h in active_adj_headers %}
+
{{ h.label }} |
{% endfor %}
@@ -489,8 +500,14 @@
{{ w.name }} |
{{ w.days }} |
R {{ w.total_paid|money }} |
+ {# Each val is now {'amount': Decimal, 'is_deductive': bool}
+ — render "-R 500.00" in red for deductive types. #}
{% for val in w.adj_values %}
-
{% if val %}R {{ val|money }}{% else %}-{% endif %} |
+
+ {% if val.amount %}
+ {% if val.is_deductive %}−{% endif %}R {{ val.amount|money_abs }}
+ {% else %}-{% endif %}
+ |
{% endfor %}
{% endfor %}
diff --git a/core/tests.py b/core/tests.py
index 3c2f912..d8bfd62 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -599,6 +599,80 @@ class CompanyCostVelocitySQLAggregateTests(TestCase):
self.assertEqual(result['avg_daily'], expected_avg_daily)
+class ReportSignedAdjustmentTotalsTests(TestCase):
+ """Regression for Finding 4: adjustment_totals and worker_breakdown
+ must include sign metadata so deductive types can render as
+ '-R 500.00' in red rather than indistinguishable from positive types."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.admin = User.objects.create_user(username='signed-adj', is_staff=True)
+ cls.project = Project.objects.create(name='SignProj')
+ cls.worker = Worker.objects.create(
+ name='SW', id_number='SW1', monthly_salary=Decimal('4000'),
+ )
+ # Two adjustments — one of each direction
+ PayrollAdjustment.objects.create(
+ worker=cls.worker, project=cls.project, type='Bonus',
+ amount=Decimal('100.00'), date=datetime.date(2026, 3, 1),
+ )
+ PayrollAdjustment.objects.create(
+ worker=cls.worker, project=cls.project, type='Deduction',
+ amount=Decimal('50.00'), date=datetime.date(2026, 3, 2),
+ )
+
+ def test_adjustment_totals_have_sign_keys(self):
+ ctx = _build_report_context(
+ datetime.date(2026, 3, 1), datetime.date(2026, 3, 31),
+ )
+ by_type = {item['type']: item for item in ctx['adjustment_totals']}
+ # Bonus is additive
+ self.assertIn('Bonus', by_type)
+ self.assertEqual(by_type['Bonus']['sign'], '+')
+ self.assertFalse(by_type['Bonus']['is_deductive'])
+ # Deduction is deductive
+ self.assertIn('Deduction', by_type)
+ self.assertEqual(by_type['Deduction']['sign'], '-')
+ self.assertTrue(by_type['Deduction']['is_deductive'])
+
+ def test_worker_breakdown_adj_values_have_is_deductive(self):
+ ctx = _build_report_context(
+ datetime.date(2026, 3, 1), datetime.date(2026, 3, 31),
+ )
+ # active_adj_headers should also have is_deductive flag
+ types_seen = [(h['label'], h['is_deductive']) for h in ctx['active_adj_headers']]
+ self.assertGreater(len(types_seen), 0)
+ # At least one entry should be flagged deductive (the Deduction we created)
+ deductive_labels = [lbl for lbl, deduct in types_seen if deduct]
+ self.assertIn('Deductions', deductive_labels)
+
+
+class ReportTotalPaidFilterCaveatTests(TestCase):
+ """Regression for Finding 3: when project/team filters are active,
+ the `total_paid_filter_caveat` flag must be True so the template
+ can decorate the Total Paid Out card with the over-counting warning."""
+
+ def test_no_filter_caveat_false(self):
+ ctx = _build_report_context(
+ datetime.date(2026, 1, 1), datetime.date(2026, 1, 31),
+ )
+ self.assertFalse(ctx['total_paid_filter_caveat'])
+
+ def test_project_filter_sets_caveat(self):
+ ctx = _build_report_context(
+ datetime.date(2026, 1, 1), datetime.date(2026, 1, 31),
+ project_ids=[1],
+ )
+ self.assertTrue(ctx['total_paid_filter_caveat'])
+
+ def test_team_filter_sets_caveat(self):
+ ctx = _build_report_context(
+ datetime.date(2026, 1, 1), datetime.date(2026, 1, 31),
+ team_ids=[1],
+ )
+ self.assertTrue(ctx['total_paid_filter_caveat'])
+
+
class CurrentOutstandingInScopeTests(TestCase):
"""Hero card 2 — 'Outstanding NOW' with optional filter scope."""
diff --git a/core/views.py b/core/views.py
index cae1f7d..cb8e768 100644
--- a/core/views.py
+++ b/core/views.py
@@ -2458,7 +2458,17 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
)
# --- Total Paid Out (sum of all payments made) ---
+ # CAVEAT (Finding 3, May 2026): when project_ids or team_ids filters
+ # are active, a PayrollRecord is INCLUDED if ANY of its linked
+ # work_logs touches the filtered project/team — but the FULL
+ # `amount_paid` is summed, not the project-attributable portion.
+ # This can over-count if a payment spans logs across multiple
+ # projects and one of them is in scope. The template surfaces this
+ # via the `total_paid_filter_caveat` flag below — see report.html.
total_paid_out = records.aggregate(total=Sum('amount_paid'))['total'] or Decimal('0.00')
+ # Flag: filters active = caveat applies. The template uses this to
+ # decorate the "Total Paid Out" label with an asterisk + tooltip.
+ total_paid_filter_caveat = bool(project_ids or team_ids)
# --- Payments by Date (total paid per day) ---
payments_by_date = (
@@ -2581,20 +2591,37 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
total=Sum('principal_amount'))['total'] or Decimal('0.00')
# --- Adjustment Summary ---
- # Group by type, use readable labels, and sort by logical grouping
+ # Group by type, use readable labels, and sort by logical grouping.
+ # The 'sign' key tells the template how to render the amount:
+ # '+' for additive types (Bonus, Overtime, New Loan, Advance Payment)
+ # '-' for deductive types (Deduction, Loan Repayment, Advance Repayment)
+ # '' for unknown types (defensive — shouldn't happen in practice)
+ # Templates use this to render "-R 500.00" in red for deductions
+ # instead of an unsigned "R 500.00" that reads as the same direction
+ # as a bonus. Finding 4 (May 2026).
adj_by_type = (
adjustments.values('type')
.annotate(total=Sum('amount'))
.order_by('type')
)
- adjustment_totals = [
- {
- 'type': item['type'],
- 'label': REPORT_ADJ_LABELS.get(item['type'], item['type']),
+
+ def _sign_for_type(t):
+ if t in ADDITIVE_TYPES:
+ return '+'
+ if t in DEDUCTIVE_TYPES:
+ return '-'
+ return ''
+
+ adjustment_totals = []
+ for item in adj_by_type:
+ t = item['type']
+ adjustment_totals.append({
+ 'type': t,
+ 'label': REPORT_ADJ_LABELS.get(t, t),
'total': item['total'],
- }
- for item in adj_by_type
- ]
+ 'sign': _sign_for_type(t),
+ 'is_deductive': t in DEDUCTIVE_TYPES,
+ })
# --- Determine which adjustment types appear (for worker table columns) ---
# Only types with non-zero totals get a column — keeps the table readable
@@ -2603,6 +2630,14 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
)
# Create matching readable labels for column headers
active_adj_labels = [REPORT_ADJ_LABELS.get(t, t) for t in active_adj_types]
+ # Parallel sign list so the worker breakdown can render "-R 500.00"
+ # for deductive-type columns. zip(labels, signs) in the template
+ # is awkward — pre-pair them into a single iterable instead.
+ # Each header_info is {'label': str, 'is_deductive': bool}.
+ active_adj_headers = [
+ {'label': REPORT_ADJ_LABELS.get(t, t), 'is_deductive': t in DEDUCTIVE_TYPES}
+ for t in active_adj_types
+ ]
# --- Worker Breakdown ---
# Per worker: days worked, total paid, and each adjustment type
@@ -2622,12 +2657,17 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
worker_breakdown = []
for wr in worker_records:
w_adjs = adjustments.filter(worker_id=wr['worker__id'])
- # Per-type amounts for this worker (only for types that exist in the period)
+ # Per-type amounts for this worker (only for types that exist in the period).
+ # Each `adj_values` entry is {'amount': Decimal, 'is_deductive': bool}
+ # so the template can render "-R 500.00" for deductive types.
adj_values = []
for adj_type in active_adj_types:
amt = w_adjs.filter(type=adj_type).aggregate(
t=Sum('amount'))['t'] or Decimal('0.00')
- adj_values.append(amt)
+ adj_values.append({
+ 'amount': amt,
+ 'is_deductive': adj_type in DEDUCTIVE_TYPES,
+ })
worker_breakdown.append({
'name': wr['worker__name'],
@@ -2658,6 +2698,7 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
),
# --- Summary ---
'total_paid_out': total_paid_out,
+ 'total_paid_filter_caveat': total_paid_filter_caveat,
'total_worker_days': total_worker_days,
'loans_outstanding': loans_outstanding,
'advances_outstanding': advances_outstanding,
@@ -2673,6 +2714,7 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
'adjustment_totals': adjustment_totals,
'active_adj_types': active_adj_types,
'active_adj_labels': active_adj_labels,
+ 'active_adj_headers': active_adj_headers,
'worker_breakdown': worker_breakdown,
# --- Hero KPI band (executive report v2) ---
'current_outstanding': _current_outstanding_in_scope(