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:
parent
f8d429c347
commit
f56c082421
18
core/migrations/0016_alter_payrolladjustment_type.py
Normal file
18
core/migrations/0016_alter_payrolladjustment_type.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 }} ✎
|
||||
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 %}
|
||||
</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';
|
||||
}
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
127
core/views.py
127
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')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user