From 255ec82cefa166a12107332d44f45b6542b90c51 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Fri, 15 May 2026 20:15:30 +0200 Subject: [PATCH] feat: add_adjustment Salary branch (project-required, pay-now/pending) --- core/forms.py | 2 +- core/tests.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++ core/views.py | 31 ++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/core/forms.py b/core/forms.py index 338451a..48fd1f5 100644 --- a/core/forms.py +++ b/core/forms.py @@ -177,7 +177,7 @@ class PayrollAdjustmentForm(forms.ModelForm): project = cleaned_data.get('project') # These types must have a project — they're tied to specific work - project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment') + project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment', 'Salary') if adj_type in project_required_types and not project: self.add_error('project', 'A project must be selected for this adjustment type.') diff --git a/core/tests.py b/core/tests.py index e1a2214..d471b0e 100644 --- a/core/tests.py +++ b/core/tests.py @@ -3586,3 +3586,58 @@ class ManagerSalariedAbsenceExclusionTests(TestCase): resp2, reverse('absence_list'), fetch_redirect_response=False) self.assertEqual(Absence.objects.filter(worker=self.daily).count(), 1) self.assertEqual(Absence.objects.filter(worker=self.mgr).count(), 0) + + +# ============================================================================= +# === MANAGER / SALARIED PAY — Salary adjustment branch in add_adjustment === +# Logging a fixed-salary manager's monthly pay: project-required, with a +# "Pay Immediately" option that mirrors the proven New Loan pay-now pattern +# (isolated PayrollRecord). Unpaid path falls through to the generic pending +# adjustment creation so it nets with deductions at dashboard Pay time. +# ============================================================================= + +class ManagerSalariedPaySalaryAdjustmentTests(TestCase): + def setUp(self): + self.admin = User.objects.create_user('mss_admin', password='x', is_staff=True) + self.client.force_login(self.admin) + self.proj = Project.objects.create(name='MSS Project') + self.mgr = Worker.objects.create( + name='Sal Mgr', id_number='MSS-M', monthly_salary=Decimal('40000'), + pay_type='fixed') + + def _post(self, **over): + data = { + 'workers': [self.mgr.id], 'type': 'Salary', + 'amount': '40000.00', 'description': 'Salary - May 2026', + 'date': _date.today().strftime('%Y-%m-%d'), + 'project': self.proj.id, + } + data.update(over) + return self.client.post(reverse('add_adjustment'), data) + + def test_salary_requires_project(self): + self._post(project='') + self.assertFalse( + PayrollAdjustment.objects.filter(worker=self.mgr, type='Salary').exists()) + + def test_salary_unpaid_creates_pending_adjustment(self): + self._post() # no pay_immediately + adj = PayrollAdjustment.objects.get(worker=self.mgr, type='Salary') + self.assertIsNone(adj.payroll_record) + self.assertEqual(adj.project, self.proj) + self.assertEqual(adj.amount, Decimal('40000.00')) + + def test_salary_pay_immediately_creates_payroll_record(self): + self._post(pay_immediately='1') + adj = PayrollAdjustment.objects.get(worker=self.mgr, type='Salary') + self.assertIsNotNone(adj.payroll_record) + self.assertEqual(adj.payroll_record.amount_paid, Decimal('40000.00')) + + def test_salary_required_in_payrolladjustmentform_clean(self): + from core.forms import PayrollAdjustmentForm + f = PayrollAdjustmentForm(data={ + 'type': 'Salary', 'worker': self.mgr.id, 'amount': '40000.00', + 'date': _date.today().strftime('%Y-%m-%d'), 'description': 'x', + }) + self.assertFalse(f.is_valid()) + self.assertIn('project', f.errors) diff --git a/core/views.py b/core/views.py index 1b2d7ef..1296503 100644 --- a/core/views.py +++ b/core/views.py @@ -4249,7 +4249,7 @@ def add_adjustment(request): except Project.DoesNotExist: pass - project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment') + project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment', 'Salary') if adj_type in project_required_types and not project: messages.error(request, 'A project must be selected for this adjustment type.') return redirect('payroll_dashboard') @@ -4390,6 +4390,35 @@ def add_adjustment(request): created_count += 1 continue # Skip the generic PayrollAdjustment creation below + # === SALARY — manager / fixed-salary monthly payment === + # Mirrors the New Loan pay-immediately pattern (isolated + # PayrollRecord). If "Pay Immediately" is unchecked the row falls + # through to the generic pending-adjustment creation below and is + # netted with any deductions/loan-repayments by + # _process_single_payment when the admin clicks Pay on the dashboard. + if adj_type == 'Salary': + pay_immediately = request.POST.get('pay_immediately') == '1' + if pay_immediately: + salary_adj = PayrollAdjustment.objects.create( + worker=worker, + type='Salary', + amount=amount, + date=adj_date, + description=description, + project=project, + ) + payroll_record = PayrollRecord.objects.create( + worker=worker, + amount_paid=amount, + date=adj_date, + ) + salary_adj.payroll_record = payroll_record + salary_adj.save() + _send_payslip_email(request, worker, payroll_record, 0, Decimal('0.00')) + created_count += 1 + continue # Skip the generic PayrollAdjustment creation below + # else: fall through to generic pending creation (keeps project) + # === ALL OTHER TYPES — create a pending adjustment === PayrollAdjustment.objects.create( worker=worker,