feat: add_adjustment Salary branch (project-required, pay-now/pending)

This commit is contained in:
Konrad du Plessis 2026-05-15 20:15:30 +02:00
parent 86b0cb9dd6
commit 255ec82cef
3 changed files with 86 additions and 2 deletions

View File

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

View File

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

View File

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