fix(adjustments): bulk-delete cascades through Loan + Overtime (critical)
Code-review found a data-integrity bug: the bulk-delete endpoint
bypassed the cascade logic that single-row delete_adjustment does
for 'New Loan', 'Advance Payment', and 'Overtime' types.
Without cascade, bulk-deleting a 'New Loan' adjustment would:
- Delete the PayrollAdjustment row
- LEAVE the linked Loan row orphaned in the DB (still shown in
loan reports, still affecting remaining_balance queries)
- LEAVE any scheduled unpaid Loan Repayment adjustments pointing
at the orphaned Loan (they would silently deduct from the
worker's next pay with no visible parent)
Bulk-deleting an 'Overtime' adjustment would leave the worker
stuck in work_log.priced_workers, making price_overtime() treat
them as already-priced even though the money is gone.
Fix: extracted _delete_adjustment_with_cascade(adj) helper that
captures the exact rules from the existing delete_adjustment view
— returns (ok, reason) so both callers can translate the outcome
into their own response shape. Both views now delegate to it.
bulk_delete_adjustments now loops over the selected rows, calls
the helper per-row, and returns JSON including skipped_reasons
(e.g. {'has_paid_repayments': 1} when a Loan with paid repayments
was refused). Also hardened the id-coercion to int so a garbled
POST payload can't crash the queryset with a ValueError.
Two new tests:
- test_bulk_delete_cascades_new_loan — loan row + unpaid repayment
must also be deleted
- test_bulk_delete_skips_loan_with_paid_repayments — refuses to
delete the loan but still processes other rows in the batch
64/64 tests pass (was 62). No API surface change visible to a user
who only uses the happy path — but the audit trail on Loans is
now safe even under bulk delete.
This commit is contained in:
parent
5f2e6d8c74
commit
4c3e90f2a7
@ -9,7 +9,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment
|
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment, Loan
|
||||||
from core.views import _build_work_log_payroll_context, _build_report_context
|
from core.views import _build_work_log_payroll_context, _build_report_context
|
||||||
|
|
||||||
|
|
||||||
@ -1133,3 +1133,88 @@ class AdjustmentsTabTests(TestCase):
|
|||||||
self.assertEqual(resp.status_code, 403)
|
self.assertEqual(resp.status_code, 403)
|
||||||
# a1 still present
|
# a1 still present
|
||||||
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
|
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
|
||||||
|
|
||||||
|
def test_bulk_delete_cascades_new_loan(self):
|
||||||
|
"""Bulk-deleting a 'New Loan' adjustment must also delete its
|
||||||
|
linked Loan row AND any still-unpaid Loan Repayment adjustments
|
||||||
|
— same cascade as the single-row delete_adjustment view. Without
|
||||||
|
this, the bulk endpoint would orphan Loan rows and leave pending
|
||||||
|
repayments in place."""
|
||||||
|
# Create a Loan + New Loan adjustment + unpaid repayment
|
||||||
|
loan = Loan.objects.create(
|
||||||
|
worker=self.w1,
|
||||||
|
principal_amount=Decimal('1000'),
|
||||||
|
remaining_balance=Decimal('1000'),
|
||||||
|
date=datetime.date(2026, 4, 1),
|
||||||
|
loan_type='loan',
|
||||||
|
)
|
||||||
|
new_loan_adj = PayrollAdjustment.objects.create(
|
||||||
|
worker=self.w1, project=self.proj, type='New Loan',
|
||||||
|
amount=Decimal('1000'), date=datetime.date(2026, 4, 1),
|
||||||
|
description='Test loan', loan=loan,
|
||||||
|
)
|
||||||
|
unpaid_repayment = PayrollAdjustment.objects.create(
|
||||||
|
worker=self.w1, project=self.proj, type='Loan Repayment',
|
||||||
|
amount=Decimal('500'), date=datetime.date(2026, 5, 1),
|
||||||
|
description='First repayment', loan=loan,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._login_admin()
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse('bulk_delete_adjustments'),
|
||||||
|
{'adjustment_ids': [new_loan_adj.id]},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
body = resp.json()
|
||||||
|
self.assertEqual(body['deleted'], 1)
|
||||||
|
|
||||||
|
# New Loan adjustment gone
|
||||||
|
self.assertFalse(PayrollAdjustment.objects.filter(id=new_loan_adj.id).exists())
|
||||||
|
# Linked Loan row gone (cascade)
|
||||||
|
self.assertFalse(Loan.objects.filter(id=loan.id).exists())
|
||||||
|
# Unpaid repayment gone (cascade)
|
||||||
|
self.assertFalse(PayrollAdjustment.objects.filter(id=unpaid_repayment.id).exists())
|
||||||
|
|
||||||
|
def test_bulk_delete_skips_loan_with_paid_repayments(self):
|
||||||
|
"""If a 'New Loan' has any paid repayments, bulk-delete must
|
||||||
|
refuse to delete it (would lose audit trail). Other rows in the
|
||||||
|
batch are unaffected."""
|
||||||
|
loan = Loan.objects.create(
|
||||||
|
worker=self.w1,
|
||||||
|
principal_amount=Decimal('1000'),
|
||||||
|
remaining_balance=Decimal('500'),
|
||||||
|
date=datetime.date(2026, 4, 1),
|
||||||
|
loan_type='loan',
|
||||||
|
)
|
||||||
|
new_loan_adj = PayrollAdjustment.objects.create(
|
||||||
|
worker=self.w1, project=self.proj, type='New Loan',
|
||||||
|
amount=Decimal('1000'), date=datetime.date(2026, 4, 1),
|
||||||
|
description='Test loan', loan=loan,
|
||||||
|
)
|
||||||
|
# One PAID repayment
|
||||||
|
pr = PayrollRecord.objects.create(
|
||||||
|
worker=self.w1, date=datetime.date(2026, 5, 1),
|
||||||
|
amount_paid=Decimal('500'),
|
||||||
|
)
|
||||||
|
PayrollAdjustment.objects.create(
|
||||||
|
worker=self.w1, project=self.proj, type='Loan Repayment',
|
||||||
|
amount=Decimal('500'), date=datetime.date(2026, 5, 1),
|
||||||
|
description='Paid', loan=loan, payroll_record=pr,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._login_admin()
|
||||||
|
# Send the New Loan plus a2 (unpaid Bonus) — expect only a2 to delete
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse('bulk_delete_adjustments'),
|
||||||
|
{'adjustment_ids': [new_loan_adj.id, self.a2.id]},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
body = resp.json()
|
||||||
|
self.assertEqual(body['deleted'], 1) # only a2
|
||||||
|
self.assertEqual(body['requested'], 2)
|
||||||
|
self.assertEqual(body['skipped_reasons'], {'has_paid_repayments': 1})
|
||||||
|
|
||||||
|
# New Loan survives, Loan survives, a2 gone
|
||||||
|
self.assertTrue(PayrollAdjustment.objects.filter(id=new_loan_adj.id).exists())
|
||||||
|
self.assertTrue(Loan.objects.filter(id=loan.id).exists())
|
||||||
|
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a2.id).exists())
|
||||||
|
|||||||
138
core/views.py
138
core/views.py
@ -3890,6 +3890,63 @@ def edit_adjustment(request, adj_id):
|
|||||||
# Removes an unpaid adjustment. Handles cascade logic for Loans and Overtime.
|
# Removes an unpaid adjustment. Handles cascade logic for Loans and Overtime.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# === ADJUSTMENT CASCADE DELETE HELPER ===
|
||||||
|
# Shared by delete_adjustment (single row) and bulk_delete_adjustments (many
|
||||||
|
# rows) so both paths have identical semantics. "New Loan" and "Advance
|
||||||
|
# Payment" each own a linked Loan row that needs teardown; "Overtime" needs
|
||||||
|
# its worker un-priced from the WorkLog. Without this helper, bulk-delete
|
||||||
|
# would orphan Loan rows and leave priced_workers stale.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_adjustment_with_cascade(adj):
|
||||||
|
"""Delete one PayrollAdjustment, cascading through its linked objects.
|
||||||
|
|
||||||
|
Returns a tuple `(ok: bool, reason: str or None)`:
|
||||||
|
- `(True, None)` — row deleted successfully (with any cascades done)
|
||||||
|
- `(False, 'paid')` — adjustment already paid; refuse
|
||||||
|
- `(False, 'has_paid_repayments')` — linked Loan has paid repayments;
|
||||||
|
deleting it would lose the repayment audit trail
|
||||||
|
|
||||||
|
Cascade rules:
|
||||||
|
- New Loan / Advance Payment: delete the linked `Loan` row plus any
|
||||||
|
still-unpaid repayment adjustments. If ANY repayment has already
|
||||||
|
been paid, abort (otherwise we'd lose history of money that
|
||||||
|
already moved).
|
||||||
|
- Overtime: remove the worker from work_log.priced_workers so the
|
||||||
|
overtime can be re-priced cleanly later.
|
||||||
|
- Other types: plain delete, no cascade.
|
||||||
|
"""
|
||||||
|
if adj.payroll_record is not None:
|
||||||
|
return False, 'paid'
|
||||||
|
|
||||||
|
adj_type = adj.type
|
||||||
|
|
||||||
|
if adj_type in ('New Loan', 'Advance Payment') and adj.loan:
|
||||||
|
repayment_type = 'Advance Repayment' if adj_type == 'Advance Payment' else 'Loan Repayment'
|
||||||
|
paid_repayments = PayrollAdjustment.objects.filter(
|
||||||
|
loan=adj.loan,
|
||||||
|
type=repayment_type,
|
||||||
|
payroll_record__isnull=False,
|
||||||
|
)
|
||||||
|
if paid_repayments.exists():
|
||||||
|
return False, 'has_paid_repayments'
|
||||||
|
# Delete all still-unpaid repayments, then the Loan itself
|
||||||
|
PayrollAdjustment.objects.filter(
|
||||||
|
loan=adj.loan,
|
||||||
|
type=repayment_type,
|
||||||
|
payroll_record__isnull=True,
|
||||||
|
).delete()
|
||||||
|
adj.loan.delete()
|
||||||
|
elif adj_type == 'Overtime' and adj.work_log:
|
||||||
|
# "Un-price" the overtime — worker can be re-priced cleanly later
|
||||||
|
adj.work_log.priced_workers.remove(adj.worker)
|
||||||
|
|
||||||
|
adj.delete()
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def delete_adjustment(request, adj_id):
|
def delete_adjustment(request, adj_id):
|
||||||
if request.method != 'POST':
|
if request.method != 'POST':
|
||||||
@ -3898,48 +3955,21 @@ def delete_adjustment(request, adj_id):
|
|||||||
return HttpResponseForbidden("Not authorized.")
|
return HttpResponseForbidden("Not authorized.")
|
||||||
|
|
||||||
adj = get_object_or_404(PayrollAdjustment, id=adj_id)
|
adj = get_object_or_404(PayrollAdjustment, id=adj_id)
|
||||||
|
|
||||||
# Can't delete adjustments that have been paid
|
|
||||||
if adj.payroll_record is not None:
|
|
||||||
messages.error(request, 'Cannot delete a paid adjustment.')
|
|
||||||
return redirect('payroll_dashboard')
|
|
||||||
|
|
||||||
adj_type = adj.type
|
adj_type = adj.type
|
||||||
worker_name = adj.worker.name
|
worker_name = adj.worker.name
|
||||||
|
|
||||||
# === CASCADE DELETE for New Loan and Advance Payment ===
|
ok, reason = _delete_adjustment_with_cascade(adj)
|
||||||
# Both create Loan records that need cleanup when deleted.
|
if not ok:
|
||||||
if adj_type in ('New Loan', 'Advance Payment') and adj.loan:
|
if reason == 'paid':
|
||||||
# Determine which repayment type to look for
|
messages.error(request, 'Cannot delete a paid adjustment.')
|
||||||
repayment_type = 'Advance Repayment' if adj_type == 'Advance Payment' else 'Loan Repayment'
|
elif reason == 'has_paid_repayments':
|
||||||
|
|
||||||
# Check if any paid repayments exist for this loan/advance
|
|
||||||
paid_repayments = PayrollAdjustment.objects.filter(
|
|
||||||
loan=adj.loan,
|
|
||||||
type=repayment_type,
|
|
||||||
payroll_record__isnull=False,
|
|
||||||
)
|
|
||||||
if paid_repayments.exists():
|
|
||||||
label = 'advance' if adj_type == 'Advance Payment' else 'loan'
|
label = 'advance' if adj_type == 'Advance Payment' else 'loan'
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request,
|
||||||
f'Cannot delete {label} for {worker_name} — it has paid repayments.'
|
f'Cannot delete {label} for {worker_name} — it has paid repayments.'
|
||||||
)
|
)
|
||||||
return redirect('payroll_dashboard')
|
return redirect('payroll_dashboard')
|
||||||
|
|
||||||
# Delete all unpaid repayments for this loan/advance, then the loan itself
|
|
||||||
PayrollAdjustment.objects.filter(
|
|
||||||
loan=adj.loan,
|
|
||||||
type=repayment_type,
|
|
||||||
payroll_record__isnull=True,
|
|
||||||
).delete()
|
|
||||||
adj.loan.delete()
|
|
||||||
|
|
||||||
elif adj_type == 'Overtime' and adj.work_log:
|
|
||||||
# "Un-price" the overtime — remove worker from priced_workers M2M
|
|
||||||
adj.work_log.priced_workers.remove(adj.worker)
|
|
||||||
|
|
||||||
adj.delete()
|
|
||||||
messages.success(request, f'{adj_type} adjustment for {worker_name} deleted.')
|
messages.success(request, f'{adj_type} adjustment for {worker_name} deleted.')
|
||||||
return redirect('payroll_dashboard')
|
return redirect('payroll_dashboard')
|
||||||
|
|
||||||
@ -3956,31 +3986,49 @@ def delete_adjustment(request, adj_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def bulk_delete_adjustments(request):
|
def bulk_delete_adjustments(request):
|
||||||
"""Delete multiple unpaid PayrollAdjustment rows in one DB call.
|
"""Delete multiple unpaid PayrollAdjustment rows with full cascade.
|
||||||
|
|
||||||
Body (form-encoded): `adjustment_ids` — repeated once per ID.
|
Body (form-encoded): `adjustment_ids` — repeated once per ID.
|
||||||
Returns JSON: `{"deleted": N, "requested": M}`.
|
Returns JSON: `{"deleted": N, "requested": M, "skipped_reasons": {...}}`.
|
||||||
Admin-only; supervisors get 403. POST-only; anything else gets 405
|
Admin-only; supervisors get 403. POST-only; anything else gets 405
|
||||||
from @require_POST.
|
from @require_POST.
|
||||||
|
|
||||||
|
Cascade: each row is deleted via `_delete_adjustment_with_cascade`
|
||||||
|
(shared with the single-row `delete_adjustment` view) so bulk and
|
||||||
|
single-row have identical semantics. Rows that fail (already paid,
|
||||||
|
or a linked loan with paid repayments) are counted in `skipped_reasons`
|
||||||
|
but don't block the rest of the batch.
|
||||||
"""
|
"""
|
||||||
if not is_admin(request.user):
|
if not is_admin(request.user):
|
||||||
return JsonResponse({'error': 'Admin access required'}, status=403)
|
return JsonResponse({'error': 'Admin access required'}, status=403)
|
||||||
|
|
||||||
ids = request.POST.getlist('adjustment_ids')
|
# Int-coerce and drop non-digit values (defensive against garbled input —
|
||||||
# Defensive filter: only DELETE rows that are still unpaid.
|
# ids are client-generated so any non-digit would crash the queryset).
|
||||||
# If a user's browser had stale state showing a paid row as
|
raw_ids = request.POST.getlist('adjustment_ids')
|
||||||
# unpaid, this quietly skips it instead of destroying payroll
|
ids = [int(v) for v in raw_ids if v.strip().isdigit()]
|
||||||
# history.
|
|
||||||
to_delete = PayrollAdjustment.objects.filter(
|
# Fetch each adjustment individually — we need the cascade helper to
|
||||||
|
# operate per-row (it deletes the linked Loan / unprices Overtime).
|
||||||
|
# Pre-filtering for .payroll_record__isnull=True is fine as an upfront
|
||||||
|
# short-circuit but the helper double-checks anyway.
|
||||||
|
adjustments = list(PayrollAdjustment.objects.filter(
|
||||||
id__in=ids,
|
id__in=ids,
|
||||||
payroll_record__isnull=True,
|
payroll_record__isnull=True,
|
||||||
)
|
).select_related('loan', 'work_log', 'worker'))
|
||||||
deleted_count = to_delete.count()
|
|
||||||
to_delete.delete()
|
deleted = 0
|
||||||
|
skipped_reasons = {}
|
||||||
|
for adj in adjustments:
|
||||||
|
ok, reason = _delete_adjustment_with_cascade(adj)
|
||||||
|
if ok:
|
||||||
|
deleted += 1
|
||||||
|
else:
|
||||||
|
skipped_reasons[reason] = skipped_reasons.get(reason, 0) + 1
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'deleted': deleted_count,
|
'deleted': deleted,
|
||||||
'requested': len(ids),
|
'requested': len(ids),
|
||||||
|
'skipped_reasons': skipped_reasons,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user