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:
parent
7ce3bfb232
commit
81753695a1
@ -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'))
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user