fix: refuse payments where deductions exceed earnings (no negative PayrollRecords)

_process_single_payment wrote logs_amount + adj_amount straight into
PayrollRecord.amount_paid with no floor — a selection where deductions
beat earnings (easy via the split-payslip checkboxes: untick the work
logs, leave a big loan repayment ticked) recorded a NEGATIVE payment,
emailed a negative payslip to Spark Receipt, and deducted the loan.

Per Konrad's decision (12 Jun 2026): REFUSE such payments. New
DeductionsExceedEarningsError raised inside the atomic block (full
rollback — loan balance and adjustments untouched); process_payment
shows a clear error toast, batch_pay names the refused worker in its
summary. Exactly-zero net stays allowed (a repayment consuming the
whole wage legitimately settles a loan with a R 0.00 payslip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-06-12 17:45:39 +02:00
parent 7ce3bfb232
commit 81753695a1
2 changed files with 130 additions and 10 deletions

View File

@ -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'))

View File

@ -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