From f56c082421198cc653a73026a554ac413dbcbef4 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 19 Feb 2026 23:17:30 +0200 Subject: [PATCH] Ver 14.08 Advance payment feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Advance Payment type to payroll adjustments. Admins can give workers partial advances against earned wages — each advance creates a payslip sent to Spark immediately, while the remaining balance stays on the dashboard. Multiple advances allowed. Final payment deducts all advances automatically. Also refactored type-grouping logic into ADDITIVE_TYPES/DEDUCTIVE_TYPES constants (was duplicated 15 times across views). Includes migration 0016 (choices-only, run: python manage.py migrate) Co-Authored-By: Claude Opus 4.6 --- .../0016_alter_payrolladjustment_type.py | 18 +++ core/models.py | 1 + core/templates/core/email/payslip_email.html | 12 +- core/templates/core/payroll_dashboard.html | 34 ++++- core/templates/core/payslip.html | 4 +- core/templates/core/pdf/payslip_pdf.html | 12 +- core/views.py | 127 +++++++++++++++--- 7 files changed, 179 insertions(+), 29 deletions(-) create mode 100644 core/migrations/0016_alter_payrolladjustment_type.py diff --git a/core/migrations/0016_alter_payrolladjustment_type.py b/core/migrations/0016_alter_payrolladjustment_type.py new file mode 100644 index 0000000..2cbe195 --- /dev/null +++ b/core/migrations/0016_alter_payrolladjustment_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-19 21:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_payrolladjustment_work_log'), + ] + + operations = [ + migrations.AlterField( + model_name='payrolladjustment', + name='type', + field=models.CharField(choices=[('BONUS', 'Bonus'), ('OVERTIME', 'Overtime'), ('DEDUCTION', 'Deduction'), ('LOAN_REPAYMENT', 'Loan Repayment'), ('LOAN', 'New Loan'), ('ADVANCE', 'Advance Payment')], default='DEDUCTION', max_length=20), + ), + ] diff --git a/core/models.py b/core/models.py index 7c3d0ee..79a543f 100644 --- a/core/models.py +++ b/core/models.py @@ -140,6 +140,7 @@ class PayrollAdjustment(models.Model): ('DEDUCTION', 'Deduction'), ('LOAN_REPAYMENT', 'Loan Repayment'), ('LOAN', 'New Loan'), + ('ADVANCE', 'Advance Payment'), ] worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments') diff --git a/core/templates/core/email/payslip_email.html b/core/templates/core/email/payslip_email.html index e235b6b..0bb6162 100644 --- a/core/templates/core/email/payslip_email.html +++ b/core/templates/core/email/payslip_email.html @@ -24,7 +24,7 @@
PAYMENT TO BENEFICIARY
{{ record.worker.name }}
-
Payslip #{{ record.id }}
+
{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}
@@ -41,12 +41,19 @@ + {% if is_advance %} + + + Advance Payment: {{ advance_description }} + R {{ advance_amount }} + + {% else %} Base Pay ({{ logs_count }} days worked) R {{ logs_amount }} - + {% for adj in adjustments %} @@ -56,6 +63,7 @@ {% endfor %} + {% endif %} diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index 94bfb25..a6a2f9e 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -163,7 +163,7 @@ {% if item.adjustments %}
{% for adj in item.adjustments %} - - {{ adj.get_type_display }}: R {{ adj.amount }} ✎ + title="{% if adj.type == 'ADVANCE' %}Click to delete (cannot edit){% else %}Click to edit{% endif %}"> + {{ adj.get_type_display }}: R {{ adj.amount }} {% if adj.type != 'ADVANCE' %}✎{% endif %} {% endfor %}
@@ -190,10 +190,10 @@ R {{ item.total_payable|intcomma }} - {% if item.total_payable > 0 %} + {% if item.total_payable > 0 or item.has_pending_advances %}
{% if item.ot_hours_unpriced > 0 %} - @@ -202,10 +202,17 @@ data-worker-id="{{ item.worker.id }}">Preview
{% csrf_token %} + {% if item.total_payable > 0 %} + {% else %} + + {% endif %}
{% else %} @@ -577,12 +584,24 @@ document.addEventListener('DOMContentLoaded', function() { let currentAdjId = null; let currentAdjType = null; - // Badge click → open Edit modal + // Badge click → open Edit modal (or Delete directly for ADVANCE) document.querySelectorAll('.adj-badge').forEach(function(badge) { badge.addEventListener('click', function() { currentAdjId = this.dataset.adjId; currentAdjType = this.dataset.adjType; + // ADVANCE cannot be edited — go straight to delete confirmation + if (currentAdjType === 'ADVANCE') { + document.getElementById('deleteAdjTypeDisplay').textContent = this.dataset.adjTypeDisplay; + document.getElementById('deleteAdjAmount').textContent = this.dataset.adjAmount; + const warning = document.getElementById('deleteAdjWarning'); + warning.textContent = 'The advance payment record and payslip remain in history. Only the pending deduction will be removed from the dashboard.'; + warning.style.display = 'block'; + document.getElementById('deleteAdjForm').action = '/payroll/adjustment/' + currentAdjId + '/delete/'; + deleteModal.show(); + return; + } + document.getElementById('editAdjWorkerName').textContent = this.dataset.adjWorker; document.getElementById('editAdjAmount').value = this.dataset.adjAmount; document.getElementById('editAdjDescription').value = this.dataset.adjDescription; @@ -616,6 +635,9 @@ document.addEventListener('DOMContentLoaded', function() { } else if (currentAdjType === 'OVERTIME') { warning.textContent = 'This will un-price the overtime so it can be re-priced later.'; warning.style.display = 'block'; + } else if (currentAdjType === 'ADVANCE') { + warning.textContent = 'The advance payment record and payslip remain in history. Only the pending deduction will be removed from the dashboard.'; + warning.style.display = 'block'; } else { warning.style.display = 'none'; } diff --git a/core/templates/core/payslip.html b/core/templates/core/payslip.html index 6308905..cea211f 100644 --- a/core/templates/core/payslip.html +++ b/core/templates/core/payslip.html @@ -97,8 +97,8 @@ {{ adj.get_type_display }} {{ adj.description }} - - {% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' %} + + {% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' or adj.type == 'ADVANCE' %} - R {{ adj.amount|intcomma }} {% else %} + R {{ adj.amount|intcomma }} diff --git a/core/templates/core/pdf/payslip_pdf.html b/core/templates/core/pdf/payslip_pdf.html index 9ce183d..a5d7e20 100644 --- a/core/templates/core/pdf/payslip_pdf.html +++ b/core/templates/core/pdf/payslip_pdf.html @@ -35,7 +35,7 @@
PAYMENT TO BENEFICIARY
{{ record.worker.name }}
-
Payslip #{{ record.id }}
+
{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}
@@ -52,12 +52,19 @@ + {% if is_advance %} + + + Advance Payment: {{ advance_description }} + R {{ advance_amount|floatformat:2 }} + + {% else %} Base Pay ({{ logs_count }} days worked) R {{ logs_amount|floatformat:2 }} - + {% for adj in adjustments %} @@ -67,6 +74,7 @@ {% endfor %} + {% endif %} diff --git a/core/views.py b/core/views.py index bec077f..57a2c46 100644 --- a/core/views.py +++ b/core/views.py @@ -20,6 +20,11 @@ from datetime import timedelta from decimal import Decimal from core.utils import render_to_pdf +# Adjustment types that ADD to pay +ADDITIVE_TYPES = ['BONUS', 'OVERTIME', 'LOAN'] +# Adjustment types that SUBTRACT from pay +DEDUCTIVE_TYPES = ['DEDUCTION', 'LOAN_REPAYMENT', 'ADVANCE'] + def is_admin(user): """Check if user has admin-level access (staff, superuser, or in Admin group).""" return user.is_staff or user.is_superuser @@ -73,9 +78,9 @@ def home(request): pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True) adj_total = Decimal('0.00') for adj in pending_adjustments: - if adj.type in ['BONUS', 'OVERTIME', 'LOAN']: + if adj.type in ADDITIVE_TYPES: adj_total += adj.amount - elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: + elif adj.type in DEDUCTIVE_TYPES: adj_total -= adj.amount total_payable = log_amount + adj_total @@ -122,9 +127,9 @@ def home(request): payroll_record__isnull=True ) for adj in project_adjustments: - if adj.type in ['BONUS', 'OVERTIME', 'LOAN']: + if adj.type in ADDITIVE_TYPES: outstanding_cost += adj.amount - elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: + elif adj.type in DEDUCTIVE_TYPES: outstanding_cost -= adj.amount if outstanding_cost > 0: @@ -434,7 +439,7 @@ def work_log_list(request): for adj in adjustments: # Determine signed amount for display/total amt = adj.amount - if adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: + if adj.type in DEDUCTIVE_TYPES: amt = -amt record = { @@ -637,7 +642,7 @@ def export_work_log_csv(request): for adj in adjustments: amt = adj.amount - if adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: + if adj.type in DEDUCTIVE_TYPES: amt = -amt is_paid = adj.payroll_record is not None @@ -744,9 +749,9 @@ def payroll_dashboard(request): pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True) adj_total = Decimal('0.00') for adj in pending_adjustments: - if adj.type in ['BONUS', 'OVERTIME', 'LOAN']: + if adj.type in ADDITIVE_TYPES: adj_total += adj.amount - elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: + elif adj.type in DEDUCTIVE_TYPES: adj_total -= adj.amount total_payable = log_amount + adj_total @@ -768,6 +773,7 @@ def payroll_dashboard(request): 'ot_data': ot_data_worker, 'ot_hours_unpriced': float(ot_hours_unpriced), 'day_rate': float(worker.day_rate), + 'has_pending_advances': pending_adjustments.filter(type='ADVANCE').exists(), }) # Paid History @@ -805,9 +811,9 @@ def payroll_dashboard(request): payroll_record__isnull=True ) for adj in project_adjustments: - if adj.type in ['BONUS', 'OVERTIME', 'LOAN']: + if adj.type in ADDITIVE_TYPES: outstanding_cost += adj.amount - elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: + elif adj.type in DEDUCTIVE_TYPES: outstanding_cost -= adj.amount if outstanding_cost > 0: @@ -929,9 +935,9 @@ def process_payment(request, worker_id): adj_amount = Decimal('0.00') for adj in pending_adjustments: - if adj.type in ['BONUS', 'OVERTIME', 'LOAN']: + if adj.type in ADDITIVE_TYPES: adj_amount += adj.amount - elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: + elif adj.type in DEDUCTIVE_TYPES: adj_amount -= adj.amount total_amount = logs_amount + adj_amount @@ -1025,14 +1031,14 @@ def preview_payslip(request, worker_id): 'type': adj.get_type_display(), 'description': adj.description or '', 'amount': float(adj.amount), - 'is_deduction': adj.type in ['DEDUCTION', 'LOAN_REPAYMENT'], + 'is_deduction': adj.type in DEDUCTIVE_TYPES, }) adj_amount = Decimal('0.00') for adj in pending_adjustments: - if adj.type in ['BONUS', 'OVERTIME', 'LOAN']: + if adj.type in ADDITIVE_TYPES: adj_amount += adj.amount - elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']: + elif adj.type in DEDUCTIVE_TYPES: adj_amount -= adj.amount total_amount = float(logs_amount + float(adj_amount)) @@ -1169,6 +1175,85 @@ def add_adjustment(request): for worker_id in worker_ids: worker = get_object_or_404(Worker, pk=worker_id) + # --- ADVANCE: validate, create adjustment + standalone PayrollRecord, send payslip --- + if adj_type == 'ADVANCE': + advance_amount = Decimal(amount) + + # Must have unpaid work logs + unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker) + log_count = unpaid_logs.count() + if log_count == 0: + skip_names.append(f"{worker.name} (no unpaid work)") + continue + + # Calculate max available (earned + existing adjustments) + logs_amount = log_count * worker.day_rate + existing_pending = worker.adjustments.filter(payroll_record__isnull=True) + existing_adj_total = Decimal('0.00') + for existing_adj in existing_pending: + if existing_adj.type in ADDITIVE_TYPES: + existing_adj_total += existing_adj.amount + elif existing_adj.type in DEDUCTIVE_TYPES: + existing_adj_total -= existing_adj.amount + max_available = logs_amount + existing_adj_total + + if advance_amount > max_available: + skip_names.append(f"{worker.name} (R{advance_amount} exceeds available R{max_available:.2f})") + continue + + # Create ADVANCE adjustment (stays pending — payroll_record=NULL) + PayrollAdjustment.objects.create( + worker=worker, + type='ADVANCE', + amount=advance_amount, + description=description or 'Advance payment', + date=date, + ) + + # Create standalone PayrollRecord (NO work_logs linked) + advance_date = date if isinstance(date, datetime.date) else timezone.now().date() + advance_record = PayrollRecord.objects.create( + worker=worker, + amount=advance_amount, + date=advance_date, + ) + + # Send advance payslip to Spark + subject = f"Advance Payment for {worker.name} - {advance_record.date}" + email_context = { + 'record': advance_record, + 'logs_count': 0, + 'logs_amount': Decimal('0.00'), + 'adjustments': [], + 'is_advance': True, + 'advance_amount': advance_amount, + 'advance_description': description or 'Advance payment', + } + html_message = render_to_string('core/email/payslip_email.html', email_context) + plain_message = strip_tags(html_message) + pdf_content = render_to_pdf('core/pdf/payslip_pdf.html', email_context) + + try: + email_obj = EmailMultiAlternatives( + subject, + plain_message, + settings.DEFAULT_FROM_EMAIL, + [settings.SPARK_RECEIPT_EMAIL], + ) + email_obj.attach_alternative(html_message, "text/html") + if pdf_content: + email_obj.attach( + f"Advance_{worker.id}_{advance_record.date}.pdf", + pdf_content, + 'application/pdf' + ) + email_obj.send() + except Exception as e: + messages.warning(request, f"Advance recorded for {worker.name}, but email failed: {e}") + + success_names.append(worker.name) + continue + # Validation for repayment OR Creation for New Loan loan = None if adj_type == 'LOAN_REPAYMENT': @@ -1199,10 +1284,13 @@ def add_adjustment(request): if success_names: names = ', '.join(success_names) - messages.success(request, f"{adj_type} of R{amount} added for {names}.") + if adj_type == 'ADVANCE': + messages.success(request, f"Advance of R{amount} processed and payslip sent for {names}.") + else: + messages.success(request, f"{adj_type} of R{amount} added for {names}.") if skip_names: names = ', '.join(skip_names) - messages.warning(request, f"Skipped (no active loan): {names}.") + messages.warning(request, f"Skipped: {names}.") return redirect('payroll_dashboard') @@ -1222,6 +1310,11 @@ def edit_adjustment(request, pk): messages.error(request, "Cannot edit a paid adjustment.") return redirect('payroll_dashboard') + # Advance amounts cannot be edited — payslip was already sent + if adj.type == 'ADVANCE': + messages.warning(request, "Advance amounts cannot be edited after creation. Delete and re-create instead.") + return redirect('payroll_dashboard') + amount = request.POST.get('amount') description = request.POST.get('description') date = request.POST.get('date')