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 @@
@@ -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
{% 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 @@
@@ -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')