From 0257b454af55c653f5147853334d25b0059d63a2 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 5 Mar 2026 10:46:58 +0200 Subject: [PATCH] Add Advance Payment system + enhanced preview modal with inline repayments Redesign Advance Payments to work like loans with tracked balances: - Add loan_type field to Loan model ('loan' or 'advance') - Move Advance Payment from DEDUCTIVE to ADDITIVE types (worker receives money) - Add new Advance Repayment type for deducting from future salary - Create/edit/delete handlers mirror New Loan behavior for advances - Loans & Advances tab with type badges and filter buttons Enhance Payslip Preview modal into "Worker Payment Hub": - Show outstanding loans & advances with balances in preview - Inline repayment form per loan (amount pre-filled, note, Deduct button) - AJAX add_repayment_ajax endpoint creates adjustment without page reload - Modal auto-refreshes after repayment showing updated net pay - New refreshPreview() JS function enables re-fetching after AJAX Other changes: - Rename History to Work History in navbar - Advance-specific payslip layout for pure advance payments - Fix JS noProjectTypes to hide Project field for advance types Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 17 +- ...004_add_loan_type_and_advance_repayment.py | 23 + core/models.py | 15 +- core/templates/base.html | 2 +- core/templates/core/payroll_dashboard.html | 501 ++++++++++++------ core/templates/core/payslip.html | 42 +- core/urls.py | 3 + core/views.py | 176 +++++- 8 files changed, 578 insertions(+), 201 deletions(-) create mode 100644 core/migrations/0004_add_loan_type_and_advance_repayment.py diff --git a/CLAUDE.md b/CLAUDE.md index a572a50..c9cacfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ staticfiles/ — Collected static assets (Bootstrap, admin) - **WorkLog** — daily attendance: date, project, team, workers (M2M), supervisor, overtime, `priced_workers` (M2M) - **PayrollRecord** — completed payments linked to WorkLogs (M2M) and Worker (FK) - **PayrollAdjustment** — bonuses, deductions, overtime, loans; linked to project (FK, optional), worker, and optionally to a Loan or WorkLog -- **Loan** — worker advances with principal and remaining_balance tracking +- **Loan** — worker loans AND advances with principal, remaining_balance, `loan_type` ('loan' or 'advance') - **ExpenseReceipt / ExpenseLineItem** — business expense records with VAT handling ## Key Business Rules @@ -58,15 +58,16 @@ staticfiles/ — Collected static assets (Bootstrap, admin) ## Payroll Constants Defined at top of views.py — used in dashboard calculations and payment processing: -- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan']` — increase worker's net pay -- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Payment']` — decrease net pay +- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment']` — increase worker's net pay +- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Repayment']` — decrease net pay ## PayrollAdjustment Type Handling - **Bonus / Deduction** — standalone, require a linked Project -- **New Loan** — creates a `Loan` record; editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments +- **New Loan** — creates a `Loan` record (`loan_type='loan'`); editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments +- **Advance Payment** — creates a `Loan` record (`loan_type='advance'`); works exactly like New Loan but tagged as "advance" in the Loans tab. Worker receives money upfront (additive). - **Overtime** — links to `WorkLog` via `adj.work_log` FK; managed by `price_overtime()` view -- **Loan Repayment** — links to `Loan` via `adj.loan` FK; loan balance changes during payment processing -- **Advance Payment** — requires a linked Project; reduces net pay +- **Loan Repayment** — links to `Loan` (loan_type='loan') via `adj.loan` FK; loan balance changes during payment processing +- **Advance Repayment** — links to `Loan` (loan_type='advance') via `adj.loan` FK; deducts from advance balance during payment processing ## Outstanding Payments Logic (Dashboard) The dashboard's outstanding amount uses **per-worker** checking, not per-log: @@ -98,6 +99,7 @@ python manage.py check # System check - POST-only mutation views pattern: check `request.method != 'POST'` → redirect - Template UI: Bootstrap 5 modals with dynamic JS, data-* attributes on clickable elements - Atomic transactions: `process_payment()` uses `select_for_update()` to prevent duplicate payments +- Payslip Preview modal ("Worker Payment Hub"): shows earnings, adjustments, net pay, **plus** active loans/advances with inline repayment forms. Uses `refreshPreview()` JS function that re-fetches after AJAX repayment submission. Repayment POSTs to `add_repayment_ajax` which creates a PayrollAdjustment (balance deduction only happens during `process_payment()`) ## URL Routes | Path | View | Purpose | @@ -114,7 +116,8 @@ python manage.py check # System check | `/payroll/adjustment/add/` | `add_adjustment` | Admin: create adjustment | | `/payroll/adjustment//edit/` | `edit_adjustment` | Admin: edit unpaid adjustment | | `/payroll/adjustment//delete/` | `delete_adjustment` | Admin: delete unpaid adjustment | -| `/payroll/preview//` | `preview_payslip` | Admin: AJAX JSON payslip preview | +| `/payroll/preview//` | `preview_payslip` | Admin: AJAX JSON payslip preview (includes active loans) | +| `/payroll/repayment//` | `add_repayment_ajax` | Admin: AJAX add loan/advance repayment from preview | | `/payroll/payslip//` | `payslip_detail` | Admin: view completed payslip | | `/receipts/create/` | `create_receipt` | Staff: expense receipt with line items | | `/import-data/` | `import_data` | Setup: run import command from browser | diff --git a/core/migrations/0004_add_loan_type_and_advance_repayment.py b/core/migrations/0004_add_loan_type_and_advance_repayment.py new file mode 100644 index 0000000..8ee8d18 --- /dev/null +++ b/core/migrations/0004_add_loan_type_and_advance_repayment.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-03-05 06:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_add_project_start_end_dates'), + ] + + operations = [ + migrations.AddField( + model_name='loan', + name='loan_type', + field=models.CharField(choices=[('loan', 'Loan'), ('advance', 'Advance')], default='loan', max_length=10), + ), + migrations.AlterField( + model_name='payrolladjustment', + name='type', + field=models.CharField(choices=[('Bonus', 'Bonus'), ('Overtime', 'Overtime'), ('Deduction', 'Deduction'), ('Loan Repayment', 'Loan Repayment'), ('New Loan', 'New Loan'), ('Advance Payment', 'Advance Payment'), ('Advance Repayment', 'Advance Repayment')], max_length=50), + ), + ] diff --git a/core/models.py b/core/models.py index 611a3a0..8debd72 100644 --- a/core/models.py +++ b/core/models.py @@ -98,7 +98,18 @@ class PayrollRecord(models.Model): return f"{self.worker.name} - {self.date}" class Loan(models.Model): + # === LOAN TYPE === + # 'loan' = traditional loan (created via "New Loan") + # 'advance' = salary advance (created via "Advance Payment") + # Both work the same way (tracked balance, repayments) but are + # labelled differently on payslips and in the Loans tab. + LOAN_TYPE_CHOICES = [ + ('loan', 'Loan'), + ('advance', 'Advance'), + ] + worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='loans') + loan_type = models.CharField(max_length=10, choices=LOAN_TYPE_CHOICES, default='loan') principal_amount = models.DecimalField(max_digits=10, decimal_places=2) remaining_balance = models.DecimalField(max_digits=10, decimal_places=2) date = models.DateField(default=timezone.now) @@ -111,7 +122,8 @@ class Loan(models.Model): super().save(*args, **kwargs) def __str__(self): - return f"{self.worker.name} - Loan - {self.date}" + label = 'Advance' if self.loan_type == 'advance' else 'Loan' + return f"{self.worker.name} - {label} - {self.date}" class PayrollAdjustment(models.Model): TYPE_CHOICES = [ @@ -121,6 +133,7 @@ class PayrollAdjustment(models.Model): ('Loan Repayment', 'Loan Repayment'), ('New Loan', 'New Loan'), ('Advance Payment', 'Advance Payment'), + ('Advance Repayment', 'Advance Repayment'), ] worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments') diff --git a/core/templates/base.html b/core/templates/base.html index 09a1484..304e71c 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -52,7 +52,7 @@ {% if user.is_staff %} diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index a616062..f5ebd40 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -69,7 +69,7 @@
- Active Loans ({{ active_loans_count }})
+ Active Loans & Advances ({{ active_loans_count }})
R {{ active_loans_balance|floatformat:2 }}
@@ -180,7 +180,7 @@ @@ -217,7 +217,7 @@ {# Show each pending adjustment as a badge #} {% for adj in wd.adjustments %} - - {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }} + {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }} {{ adj.type }} {% if adj.project %}({{ adj.project.name }}){% endif %} @@ -300,7 +300,7 @@ {% for adj in record.adjustments.all %} - + {{ adj.type }}: R {{ adj.amount|floatformat:2 }} {% empty %} @@ -335,11 +335,11 @@
@@ -349,6 +349,7 @@ Worker + Type Principal Balance Date @@ -360,6 +361,13 @@ {% for loan in loans %} {{ loan.worker.name }} + + {% if loan.loan_type == 'advance' %} + Advance + {% else %} + Loan + {% endif %} + R {{ loan.principal_amount|floatformat:2 }} R {{ loan.remaining_balance|floatformat:2 }} {{ loan.date }} @@ -374,9 +382,9 @@ {% empty %} - + - {% if loan_filter == 'active' %}No active loans.{% else %}No loan history.{% endif %} + {% if loan_filter == 'active' %}No active loans or advances.{% else %}No loan/advance history.{% endif %} {% endfor %} @@ -649,7 +657,7 @@ + {% endif %}
diff --git a/core/urls.py b/core/urls.py index 504f962..15c413b 100644 --- a/core/urls.py +++ b/core/urls.py @@ -46,6 +46,9 @@ urlpatterns = [ # Preview a worker's payslip (AJAX — returns JSON) path('payroll/preview//', views.preview_payslip, name='preview_payslip'), + # Add a repayment from the payslip preview modal (AJAX — returns JSON) + path('payroll/repayment//', views.add_repayment_ajax, name='add_repayment_ajax'), + # View a completed payslip (print-friendly page) path('payroll/payslip//', views.payslip_detail, name='payslip_detail'), diff --git a/core/views.py b/core/views.py index 1f305e8..b14d6b8 100644 --- a/core/views.py +++ b/core/views.py @@ -30,10 +30,10 @@ from .forms import AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, # === PAYROLL CONSTANTS === # These define which adjustment types ADD to a worker's pay vs SUBTRACT from it. -# "New Loan" is additive because the worker receives money upfront. -# "Loan Repayment" and "Advance Payment" are deductive — they reduce net pay. -ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan'] -DEDUCTIVE_TYPES = ['Deduction', 'Loan Repayment', 'Advance Payment'] +# "New Loan" and "Advance Payment" are additive — the worker receives money upfront. +# "Loan Repayment" and "Advance Repayment" are deductive — they reduce net pay. +ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan', 'Advance Payment'] +DEDUCTIVE_TYPES = ['Deduction', 'Loan Repayment', 'Advance Repayment'] # === PERMISSION HELPERS === @@ -1138,8 +1138,8 @@ def process_payment(request, worker_id): adj.payroll_record = payroll_record adj.save() - # If this is a loan repayment, deduct from the loan balance - if adj.type == 'Loan Repayment' and adj.loan: + # If this is a loan or advance repayment, deduct from the balance + if adj.type in ('Loan Repayment', 'Advance Repayment') and adj.loan: adj.loan.remaining_balance -= adj.amount if adj.loan.remaining_balance <= 0: adj.loan.remaining_balance = Decimal('0.00') @@ -1154,7 +1154,18 @@ def process_payment(request, worker_id): # Lazy import — avoids crashing the app if xhtml2pdf isn't installed from .utils import render_to_pdf - subject = f"Payslip for {worker.name} - {payroll_record.date}" + # === DETECT ADVANCE-ONLY PAYMENT === + # If the payment has 0 work logs and consists of only an Advance Payment + # adjustment, use the special advance payslip layout (shows the advance + # as a positive amount instead of the confusing "0 days + deduction" format). + advance_adj = None + if log_count == 0: + adjs_list = list(payroll_record.adjustments.all()) + if len(adjs_list) == 1 and adjs_list[0].type == 'Advance Payment': + advance_adj = adjs_list[0] + + is_advance = advance_adj is not None + subject = f"{'Advance ' if is_advance else ''}Payslip for {worker.name} - {payroll_record.date}" # Context for both the HTML email body and the PDF attachment email_context = { @@ -1163,6 +1174,9 @@ def process_payment(request, worker_id): 'logs_amount': logs_amount, 'adjustments': payroll_record.adjustments.all(), 'deductive_types': DEDUCTIVE_TYPES, + 'is_advance': is_advance, + 'advance_amount': advance_adj.amount if advance_adj else None, + 'advance_description': advance_adj.description if advance_adj else '', } # 1. Render HTML email body @@ -1312,7 +1326,7 @@ def add_adjustment(request): except Project.DoesNotExist: pass - project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment') + project_required_types = ('Overtime', 'Bonus', 'Deduction') 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') @@ -1326,23 +1340,44 @@ def add_adjustment(request): loan = None + # === LOAN REPAYMENT — find the worker's active loan === if adj_type == 'Loan Repayment': - # Find the worker's active loan - loan = worker.loans.filter(active=True).first() + loan = worker.loans.filter(active=True, loan_type='loan').first() if not loan: messages.warning(request, f'{worker.name} has no active loan — skipped.') continue + # === ADVANCE REPAYMENT — find the worker's active advance === + if adj_type == 'Advance Repayment': + loan = worker.loans.filter(active=True, loan_type='advance').first() + if not loan: + messages.warning(request, f'{worker.name} has no active advance — skipped.') + continue + + # === NEW LOAN — create a Loan record (loan_type='loan') === if adj_type == 'New Loan': - # Create a new Loan object first loan = Loan.objects.create( worker=worker, + loan_type='loan', principal_amount=amount, remaining_balance=amount, date=adj_date, reason=description, ) + # === ADVANCE PAYMENT — create a Loan record (loan_type='advance') === + # Works just like New Loan but tagged as 'advance' so it shows + # separately in the Loans tab and uses "Advance Repayment" to deduct. + if adj_type == 'Advance Payment': + loan = Loan.objects.create( + worker=worker, + loan_type='advance', + principal_amount=amount, + remaining_balance=amount, + date=adj_date, + reason=description or 'Salary advance', + ) + PayrollAdjustment.objects.create( worker=worker, type=adj_type, @@ -1378,9 +1413,9 @@ def edit_adjustment(request, adj_id): messages.error(request, 'Cannot edit a paid adjustment.') return redirect('payroll_dashboard') - # Can't edit Advance Payments - if adj.type == 'Advance Payment': - messages.warning(request, 'Advance payments cannot be edited.') + # Can't edit repayment adjustments (managed by the loan system) + if adj.type in ('Loan Repayment', 'Advance Repayment'): + messages.warning(request, 'Repayment adjustments cannot be edited directly.') return redirect('payroll_dashboard') # Update fields @@ -1415,8 +1450,8 @@ def edit_adjustment(request, adj_id): adj.save() - # If it's a Loan adjustment, sync the loan details - if adj.type == 'New Loan' and adj.loan: + # If it's a Loan or Advance adjustment, sync the loan details + if adj.type in ('New Loan', 'Advance Payment') and adj.loan: adj.loan.principal_amount = adj.amount adj.loan.remaining_balance = adj.amount adj.loan.reason = adj.description @@ -1448,24 +1483,30 @@ def delete_adjustment(request, adj_id): adj_type = adj.type worker_name = adj.worker.name - if adj_type == 'New Loan' and adj.loan: - # Check if any paid repayments exist for this loan + # === CASCADE DELETE for New Loan and Advance Payment === + # Both create Loan records that need cleanup when deleted. + if adj_type in ('New Loan', 'Advance Payment') and adj.loan: + # Determine which repayment type to look for + repayment_type = 'Advance Repayment' if adj_type == 'Advance Payment' else 'Loan Repayment' + + # Check if any paid repayments exist for this loan/advance paid_repayments = PayrollAdjustment.objects.filter( loan=adj.loan, - type='Loan Repayment', + type=repayment_type, payroll_record__isnull=False, ) if paid_repayments.exists(): + label = 'advance' if adj_type == 'Advance Payment' else 'loan' messages.error( request, - f'Cannot delete loan for {worker_name} — it has paid repayments.' + f'Cannot delete {label} for {worker_name} — it has paid repayments.' ) return redirect('payroll_dashboard') - # Delete all unpaid repayments for this loan, then the loan itself + # Delete all unpaid repayments for this loan/advance, then the loan itself PayrollAdjustment.objects.filter( loan=adj.loan, - type='Loan Repayment', + type=repayment_type, payroll_record__isnull=True, ).delete() adj.loan.delete() @@ -1523,6 +1564,22 @@ def preview_payslip(request, worker_id): 'project': adj.project.name if adj.project else '', }) + # === ACTIVE LOANS & ADVANCES === + # Include the worker's outstanding balances so the admin can see the + # full picture and add repayments directly from the preview modal. + active_loans = worker.loans.filter(active=True).order_by('-date') + loans_list = [] + for loan in active_loans: + loans_list.append({ + 'id': loan.id, + 'type': loan.loan_type, # 'loan' or 'advance' + 'type_label': loan.get_loan_type_display(), # 'Loan' or 'Advance' + 'principal': float(loan.principal_amount), + 'balance': float(loan.remaining_balance), + 'date': loan.date.strftime('%Y-%m-%d'), + 'reason': loan.reason or '', + }) + return JsonResponse({ 'worker_name': worker.name, 'worker_id_number': worker.id_number, @@ -1533,6 +1590,71 @@ def preview_payslip(request, worker_id): 'adj_total': adj_total, 'net_pay': log_amount + adj_total, 'logs': unpaid_logs, + 'active_loans': loans_list, + }) + + +# ============================================================================= +# === ADD REPAYMENT (AJAX) === +# Creates a Loan Repayment or Advance Repayment adjustment for a single worker. +# Called via AJAX POST from the Payslip Preview modal's inline repayment form. +# Returns JSON so the modal can refresh in-place without a page reload. +# ============================================================================= + +@login_required +def add_repayment_ajax(request, worker_id): + """AJAX endpoint: add a repayment adjustment and return JSON response.""" + if request.method != 'POST': + return JsonResponse({'error': 'POST required'}, status=405) + if not is_admin(request.user): + return JsonResponse({'error': 'Not authorized'}, status=403) + + worker = get_object_or_404(Worker, id=worker_id) + + # Parse the POST body (sent as JSON from fetch()) + try: + body = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({'error': 'Invalid JSON'}, status=400) + + loan_id = body.get('loan_id') + amount_str = body.get('amount', '0') + description = body.get('description', '') + + # Validate: loan exists, belongs to this worker, and is active + try: + loan = Loan.objects.get(id=int(loan_id), worker=worker, active=True) + except (Loan.DoesNotExist, ValueError, TypeError): + return JsonResponse({'error': 'No active loan/advance found.'}, status=400) + + # Validate: amount is positive + try: + amount = Decimal(str(amount_str)) + if amount <= 0: + raise ValueError + except (ValueError, Exception): + return JsonResponse({'error': 'Please enter a valid amount greater than zero.'}, status=400) + + # Cap the repayment at the remaining balance (prevent over-repaying) + if amount > loan.remaining_balance: + amount = loan.remaining_balance + + # Pick the right repayment type based on loan type + repayment_type = 'Advance Repayment' if loan.loan_type == 'advance' else 'Loan Repayment' + + # Create the adjustment (balance deduction happens later during process_payment) + PayrollAdjustment.objects.create( + worker=worker, + type=repayment_type, + amount=amount, + date=timezone.now().date(), + description=description or f'{loan.get_loan_type_display()} repayment', + loan=loan, + ) + + return JsonResponse({ + 'success': True, + 'message': f'{repayment_type} of R {amount:.2f} added for {worker.name}.', }) @@ -1564,6 +1686,14 @@ def payslip_detail(request, pk): # Calculate net adjustment amount (additive minus deductive) adjustments_net = record.amount_paid - base_pay + # === DETECT ADVANCE-ONLY PAYMENT === + # If payment has 0 work logs and a single Advance Payment adjustment, + # show a cleaner "advance payslip" layout instead of "0 days worked". + adjs_list = list(adjustments) + advance_adj = None + if logs.count() == 0 and len(adjs_list) == 1 and adjs_list[0].type == 'Advance Payment': + advance_adj = adjs_list[0] + context = { 'record': record, 'logs': logs, @@ -1572,6 +1702,8 @@ def payslip_detail(request, pk): 'adjustments_net': adjustments_net, 'adjustments_net_abs': abs(adjustments_net), 'deductive_types': DEDUCTIVE_TYPES, + 'is_advance': advance_adj is not None, + 'advance_adj': advance_adj, } return render(request, 'core/payslip.html', context)