Ver 14.08 Advance payment feature

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 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-19 23:17:30 +02:00
parent f8d429c347
commit f56c082421
7 changed files with 179 additions and 29 deletions

View File

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

View File

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

View File

@ -24,7 +24,7 @@
<div class="header">
<div class="sub-header">PAYMENT TO BENEFICIARY</div>
<div class="beneficiary-name">{{ record.worker.name }}</div>
<div class="title">Payslip #{{ record.id }}</div>
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
</div>
<div class="meta">
@ -41,12 +41,19 @@
</tr>
</thead>
<tbody>
{% if is_advance %}
<!-- Advance Payment -->
<tr>
<td>Advance Payment: {{ advance_description }}</td>
<td style="text-align: right;">R {{ advance_amount }}</td>
</tr>
{% else %}
<!-- Base Pay -->
<tr>
<td>Base Pay ({{ logs_count }} days worked)</td>
<td style="text-align: right;">R {{ logs_amount }}</td>
</tr>
<!-- Adjustments -->
{% for adj in adjustments %}
<tr>
@ -56,6 +63,7 @@
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>

View File

@ -163,7 +163,7 @@
{% if item.adjustments %}
<div class="mt-1">
{% for adj in item.adjustments %}
<span class="badge bg-secondary opacity-75 small adj-badge" role="button"
<span class="badge {% if adj.type == 'ADVANCE' %}bg-info text-dark{% else %}bg-secondary opacity-75{% endif %} small adj-badge" role="button"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-type-display="{{ adj.get_type_display }}"
@ -171,8 +171,8 @@
data-adj-description="{{ adj.description }}"
data-adj-date="{{ adj.date|date:'Y-m-d' }}"
data-adj-worker="{{ item.worker.name }}"
title="Click to edit">
{{ adj.get_type_display }}: R {{ adj.amount }} &#9998;
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' %}&#9998;{% endif %}
</span>
{% endfor %}
</div>
@ -190,10 +190,10 @@
R {{ item.total_payable|intcomma }}
</td>
<td class="text-end pe-4">
{% if item.total_payable > 0 %}
{% if item.total_payable > 0 or item.has_pending_advances %}
<div class="d-flex gap-1 justify-content-end">
{% if item.ot_hours_unpriced > 0 %}
<button type="button" class="btn btn-sm btn-warning text-dark price-ot-btn"
<button type="button" class="btn btn-sm btn-warning text-dark price-ot-btn"
data-worker-id="{{ item.worker.id }}" data-worker-name="{{ item.worker.name }}">
Price OT
</button>
@ -202,10 +202,17 @@
data-worker-id="{{ item.worker.id }}">Preview</button>
<form action="{% url 'process_payment' item.worker.id %}" method="post" class="d-inline">
{% csrf_token %}
{% if item.total_payable > 0 %}
<button type="submit" class="btn btn-sm btn-success"
onclick="return confirm('Confirm payment of R {{ item.total_payable }} to {{ item.worker.name }}? This will email the receipt.')">
Pay Now
</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-success"
onclick="return confirm('Close out R0 balance for {{ item.worker.name }}? Advances cover all earned wages. This will mark work logs as paid.')">
Close Out (R0)
</button>
{% endif %}
</form>
</div>
{% 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';
}

View File

@ -97,8 +97,8 @@
<span class="badge bg-secondary text-uppercase">{{ adj.get_type_display }}</span>
</td>
<td>{{ adj.description }}</td>
<td class="text-end {% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' %}text-danger{% else %}text-success{% endif %}">
{% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' %}
<td class="text-end {% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' or adj.type == 'ADVANCE' %}text-danger{% else %}text-success{% endif %}">
{% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' or adj.type == 'ADVANCE' %}
- R {{ adj.amount|intcomma }}
{% else %}
+ R {{ adj.amount|intcomma }}

View File

@ -35,7 +35,7 @@
<div class="header">
<div class="sub-header">PAYMENT TO BENEFICIARY</div>
<div class="beneficiary-name">{{ record.worker.name }}</div>
<div class="title">Payslip #{{ record.id }}</div>
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
</div>
<div class="meta">
@ -52,12 +52,19 @@
</tr>
</thead>
<tbody>
{% if is_advance %}
<!-- Advance Payment -->
<tr>
<td>Advance Payment: {{ advance_description }}</td>
<td class="text-right">R {{ advance_amount|floatformat:2 }}</td>
</tr>
{% else %}
<!-- Base Pay -->
<tr>
<td>Base Pay ({{ logs_count }} days worked)</td>
<td class="text-right">R {{ logs_amount|floatformat:2 }}</td>
</tr>
<!-- Adjustments -->
{% for adj in adjustments %}
<tr>
@ -67,6 +74,7 @@
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>

View File

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