diff --git a/core/tests.py b/core/tests.py index 205cbd1..50000a7 100644 --- a/core/tests.py +++ b/core/tests.py @@ -3743,3 +3743,85 @@ class PaymentEmailFailureTests(TestCase): self.assertTrue( any('1 email(s) failed to send' in m for m in msgs), f'expected batch summary to count the email failure, got: {msgs}') + + +# ============================================================================= +# === AUDIT FIX #4 — REFUSE PAYMENTS WHERE DEDUCTIONS EXCEED EARNINGS === +# Konrad's decision (12 Jun 2026): when a payment selection's deductions +# are bigger than its earnings (e.g. a R1,500 loan repayment against +# R400 of work), the payment is REFUSED with a clear message — never +# recorded as a negative PayrollRecord, never clamped to zero. The loan +# balance must stay untouched and the adjustment must stay unpaid so the +# admin can fix the selection and retry. +# ============================================================================= + +class NegativePaymentGuardTests(TestCase): + """Deductions > earnings: refuse, leave everything unpaid and intact.""" + + def setUp(self): + self.admin = User.objects.create_user( + username='negpay_admin', password='x', is_staff=True) + self.client.force_login(self.admin) + self.project = Project.objects.create(name='NP Project') + # Daily rate = 8000 / 20 = R400; one logged day = R400 earnings. + self.worker = Worker.objects.create( + name='NP Worker', id_number='NP-1', monthly_salary=Decimal('8000')) + log = WorkLog.objects.create( + date=datetime.date(2026, 6, 2), project=self.project, + supervisor=self.admin) + log.workers.add(self.worker) + # A R1,500 repayment dwarfs the R400 of earnings → net would be -1100. + self.loan = Loan.objects.create( + worker=self.worker, principal_amount=Decimal('1500.00'), + loan_type='loan') + self.repayment = PayrollAdjustment.objects.create( + worker=self.worker, type='Loan Repayment', + amount=Decimal('1500.00'), date=datetime.date(2026, 6, 2), + loan=self.loan) + + def test_individual_payment_refused_not_recorded_negative(self): + resp = self.client.post( + reverse('process_payment', args=[self.worker.id]), follow=True) + self.assertEqual(resp.status_code, 200) + # Nothing was recorded... + self.assertEqual( + PayrollRecord.objects.filter(worker=self.worker).count(), 0) + # ...the loan balance is untouched and the repayment still pending... + self.loan.refresh_from_db() + self.repayment.refresh_from_db() + self.assertEqual(self.loan.remaining_balance, Decimal('1500.00')) + self.assertIsNone(self.repayment.payroll_record) + # ...and the admin was told why. + msgs = [str(m) for m in resp.context['messages']] + self.assertTrue( + any('exceed' in m.lower() for m in msgs), + f'expected a deductions-exceed-earnings refusal message, got: {msgs}') + + def test_batch_pay_refuses_and_reports(self): + resp = self.client.post( + reverse('batch_pay'), + data=json.dumps({'workers': [{'worker_id': self.worker.id}]}), + content_type='application/json', + follow=True) + self.assertEqual(resp.status_code, 200) + self.assertEqual( + PayrollRecord.objects.filter(worker=self.worker).count(), 0) + self.loan.refresh_from_db() + self.assertEqual(self.loan.remaining_balance, Decimal('1500.00')) + msgs = [str(m) for m in resp.context['messages']] + self.assertTrue( + any('NP Worker' in m and 'exceed' in m.lower() for m in msgs), + f'expected the batch summary to name the refused worker, got: {msgs}') + + def test_zero_net_payment_still_allowed(self): + # Repayment EXACTLY equal to earnings is legitimate (settles the + # loan with the whole wage) — only strictly-negative is refused. + self.repayment.amount = Decimal('400.00') + self.repayment.save() + resp = self.client.post( + reverse('process_payment', args=[self.worker.id]), follow=True) + self.assertEqual(resp.status_code, 200) + record = PayrollRecord.objects.get(worker=self.worker) + self.assertEqual(record.amount_paid, Decimal('0.00')) + self.loan.refresh_from_db() + self.assertEqual(self.loan.remaining_balance, Decimal('1100.00')) diff --git a/core/views.py b/core/views.py index 602a1fb..d9f814e 100644 --- a/core/views.py +++ b/core/views.py @@ -3577,10 +3577,23 @@ def payroll_dashboard(request): # and handles loan repayment deductions — all inside an atomic transaction. # ============================================================================= +class DeductionsExceedEarningsError(Exception): + """A payment selection's deductions are bigger than its earnings. + + Business rule (Konrad, 12 Jun 2026): such payments are REFUSED — we + never record a negative PayrollRecord and never clamp to zero. The + admin reduces the deduction (e.g. a smaller loan repayment) or adds + more work days to the selection, then retries. Raising inside the + atomic block below rolls everything back, so the loan balance and + all adjustments stay exactly as they were. + """ + + def _process_single_payment(worker_id, selected_log_ids=None, selected_adj_ids=None): """ Process payment for one worker inside an atomic transaction. Returns (payroll_record, log_count, logs_amount) on success, or None if nothing to pay. + Raises DeductionsExceedEarningsError if the net would be negative. - worker_id: the Worker's PK - selected_log_ids: list of WorkLog IDs to include (None = all unpaid) @@ -3623,6 +3636,17 @@ def _process_single_payment(worker_id, selected_log_ids=None, selected_adj_ids=N total_amount = logs_amount + adj_amount + # === GUARD: never record a negative payment === + # Deductions larger than earnings means there is nothing to pay + # out — refuse instead of writing a negative amount to the books. + # (Exactly zero IS allowed: a repayment that consumes the whole + # wage legitimately settles a loan with a R 0.00 payslip.) + if total_amount < Decimal('0.00'): + raise DeductionsExceedEarningsError( + f'{worker.name}: deductions (R {-adj_amount:,.2f}) exceed ' + f'earnings (R {logs_amount:,.2f}) for this selection' + ) + # Create the PayrollRecord payroll_record = PayrollRecord.objects.create( worker=worker, @@ -3679,11 +3703,19 @@ def process_payment(request, worker_id): selected_log_ids = [int(x) for x in request.POST.getlist('selected_log_ids') if x.isdigit()] selected_adj_ids = [int(x) for x in request.POST.getlist('selected_adj_ids') if x.isdigit()] - result = _process_single_payment( - worker_id, - selected_log_ids=selected_log_ids or None, - selected_adj_ids=selected_adj_ids or None, - ) + try: + result = _process_single_payment( + worker_id, + selected_log_ids=selected_log_ids or None, + selected_adj_ids=selected_adj_ids or None, + ) + except DeductionsExceedEarningsError as e: + messages.error( + request, + f'Payment refused — {e}. Reduce the deduction amount or include ' + f'more work days, then try again.' + ) + return redirect('payroll_dashboard') if result is None: messages.warning(request, f'No pending payments for {worker.name} — already paid or nothing owed.') @@ -4008,11 +4040,17 @@ def batch_pay(request): errors.append(f'Worker ID {worker_id} not found or inactive.') continue - result = _process_single_payment( - worker_id, - selected_log_ids=log_ids or None, - selected_adj_ids=adj_ids or None, - ) + try: + result = _process_single_payment( + worker_id, + selected_log_ids=log_ids or None, + selected_adj_ids=adj_ids or None, + ) + except DeductionsExceedEarningsError as e: + # Refused, nothing recorded — name the worker in the summary + # so the admin knows who still needs attention. + errors.append(f'Payment refused — {e}.') + continue if result is None: continue # Nothing to pay — silently skip