Add batch pay feature and fix pay period cutoff logic

Batch Pay: new button on payroll dashboard lets admins pay multiple
workers at once using team pay schedules. Shows preview modal with
eligible workers, then processes all payments in one click.

Fix: "Split at Pay Date" now uses cutoff_date (end of last completed
period) instead of current period end. This includes ALL overdue work
across completed periods, not just one period.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-03-24 22:16:21 +02:00
parent 79b6345cb9
commit 2e6881b7a4
3 changed files with 667 additions and 64 deletions

View File

@ -13,6 +13,9 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Payroll Dashboard</h1>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary shadow-sm" id="batchPayBtn" title="Pay all workers with a configured pay schedule for their current pay period">
<i class="fas fa-users fa-sm me-1"></i> Batch Pay
</button>
<button type="button" class="btn btn-accent shadow-sm" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
<i class="fas fa-plus fa-sm me-1"></i> Add Adjustment
</button>
@ -678,6 +681,33 @@
</div>
{# --- PREVIEW PAYSLIP MODAL --- #}
{# === BATCH PAY MODAL === #}
{# Shows a preview of which workers will be paid (based on team pay schedules), #}
{# then lets the admin confirm to process all payments at once. #}
<div class="modal fade" id="batchPayModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-users me-2"></i>Batch Pay by Schedule</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="batchPayModalBody">
{# Content loaded via JavaScript #}
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2 small">Loading preview...</p>
</div>
</div>
<div class="modal-footer" id="batchPayModalFooter" style="display:none;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-accent" id="confirmBatchPayBtn">
<i class="fas fa-money-bill-wave me-1"></i> Confirm & Pay All
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="previewPayslipModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
@ -1462,10 +1492,13 @@ document.addEventListener('DOMContentLoaded', function() {
var infoIcon = document.createElement('i');
infoIcon.className = 'fas fa-calendar-alt me-2';
periodInfo.appendChild(infoIcon);
// Show current period info + cutoff date
// cutoff_date = end of the last COMPLETED period (everything due for payment)
periodInfo.appendChild(document.createTextNode(
data.pay_period.team_name + ' \u2014 ' +
data.pay_period.frequency + ' pay period: ' +
data.pay_period.start + ' to ' + data.pay_period.end
data.pay_period.frequency + ' | Current period: ' +
data.pay_period.start + ' to ' + data.pay_period.end +
' | Pay through: ' + data.pay_period.cutoff_date
));
modalBody.appendChild(periodInfo);
@ -1476,17 +1509,20 @@ document.addEventListener('DOMContentLoaded', function() {
splitIcon.className = 'fas fa-cut me-1';
splitBtn.appendChild(splitIcon);
splitBtn.appendChild(document.createTextNode(
'Split at Pay Date (' + data.pay_period.end + ')'
'Split at Pay Date (up to ' + data.pay_period.cutoff_date + ')'
));
splitBtn.addEventListener('click', function() {
var periodEnd = data.pay_period.end;
// Uncheck logs after the pay period end date
// Use cutoff_date (end of last completed period) — includes ALL
// overdue work, not just one period. Leaves current in-progress
// period for the next pay run.
var cutoff = data.pay_period.cutoff_date;
// Uncheck logs after the cutoff date
modalBody.querySelectorAll('.log-checkbox').forEach(function(cb) {
cb.checked = (cb.dataset.logDate <= periodEnd);
cb.checked = (cb.dataset.logDate <= cutoff);
});
// Uncheck adjustments after the pay period end date
// Uncheck adjustments after the cutoff date
modalBody.querySelectorAll('.adj-checkbox').forEach(function(cb) {
cb.checked = (cb.dataset.adjDate <= periodEnd);
cb.checked = (cb.dataset.adjDate <= cutoff);
});
recalcNetPay();
});
@ -1966,6 +2002,337 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// =================================================================
// === BATCH PAY FLOW ===
// Step 1: Click "Batch Pay" → fetch preview → show confirmation modal
// Step 2: Click "Confirm & Pay All" → POST to batch_pay → redirect
// =================================================================
// --- Helper: Format number as money (e.g., 45320 → "45,320.00") ---
function formatMoney(num) {
return Number(num).toLocaleString('en-ZA', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// --- Helper: Build the "Skipped workers" collapsible section ---
function buildSkippedSection(skipped) {
var div = document.createElement('div');
div.className = 'mt-2';
var toggle = document.createElement('a');
toggle.href = '#';
toggle.className = 'text-muted small';
var warnIcon = document.createElement('i');
warnIcon.className = 'fas fa-exclamation-triangle me-1';
toggle.appendChild(warnIcon);
toggle.appendChild(document.createTextNode(skipped.length + ' worker(s) skipped '));
var chevron = document.createElement('i');
chevron.className = 'fas fa-chevron-down ms-1';
toggle.appendChild(chevron);
div.appendChild(toggle);
var list = document.createElement('div');
list.style.display = 'none';
list.className = 'mt-2';
var ul = document.createElement('ul');
ul.className = 'list-unstyled small text-muted mb-0';
skipped.forEach(function(s) {
var li = document.createElement('li');
li.textContent = s.worker_name + ' — ' + s.reason;
ul.appendChild(li);
});
list.appendChild(ul);
div.appendChild(list);
toggle.addEventListener('click', function(e) {
e.preventDefault();
list.style.display = list.style.display === 'none' ? 'block' : 'none';
});
return div;
}
// --- Helper: Update batch summary when checkboxes change ---
function updateBatchSummary(data, summaryEl) {
var checked = document.querySelectorAll('.batch-worker-cb:checked');
var count = checked.length;
var total = 0;
checked.forEach(function(cb) {
var idx = parseInt(cb.dataset.index);
total += data.eligible[idx].net_pay;
});
// Clear and rebuild summary safely
while (summaryEl.firstChild) summaryEl.removeChild(summaryEl.firstChild);
var leftSpan = document.createElement('span');
var strong = document.createElement('strong');
strong.textContent = count + ' worker(s)';
leftSpan.appendChild(strong);
leftSpan.appendChild(document.createTextNode(' selected for payment'));
summaryEl.appendChild(leftSpan);
var rightSpan = document.createElement('span');
rightSpan.className = 'fw-bold';
rightSpan.textContent = 'Total: R ' + formatMoney(total);
summaryEl.appendChild(rightSpan);
}
// --- Helper: Build a table row for an eligible worker ---
function buildWorkerRow(w, idx) {
var tr = document.createElement('tr');
// Checkbox cell
var tdCb = document.createElement('td');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'batch-worker-cb';
cb.dataset.index = idx;
cb.checked = true;
tdCb.appendChild(cb);
tr.appendChild(tdCb);
// Worker name
var tdName = document.createElement('td');
tdName.textContent = w.worker_name;
tr.appendChild(tdName);
// Team badge
var tdTeam = document.createElement('td');
var badge = document.createElement('span');
badge.className = 'badge bg-secondary';
badge.textContent = w.team_name;
tdTeam.appendChild(badge);
tr.appendChild(tdTeam);
// Period
var tdPeriod = document.createElement('td');
var small = document.createElement('small');
small.textContent = w.period;
tdPeriod.appendChild(small);
tr.appendChild(tdPeriod);
// Days
var tdDays = document.createElement('td');
tdDays.className = 'text-end';
tdDays.textContent = w.days;
tr.appendChild(tdDays);
// Net pay
var tdNet = document.createElement('td');
tdNet.className = 'text-end fw-bold';
tdNet.textContent = 'R ' + formatMoney(w.net_pay);
tr.appendChild(tdNet);
return tr;
}
var batchPayBtn = document.getElementById('batchPayBtn');
if (batchPayBtn) {
batchPayBtn.addEventListener('click', function() {
var modal = new bootstrap.Modal(document.getElementById('batchPayModal'));
var body = document.getElementById('batchPayModalBody');
var footer = document.getElementById('batchPayModalFooter');
footer.style.display = 'none';
// Show loading spinner
while (body.firstChild) body.removeChild(body.firstChild);
var loadDiv = document.createElement('div');
loadDiv.className = 'text-center py-4';
var spinner = document.createElement('div');
spinner.className = 'spinner-border text-primary';
spinner.setAttribute('role', 'status');
loadDiv.appendChild(spinner);
var loadText = document.createElement('p');
loadText.className = 'text-muted mt-2 small';
loadText.textContent = 'Calculating pay periods...';
loadDiv.appendChild(loadText);
body.appendChild(loadDiv);
modal.show();
// Fetch batch pay preview (dry run — no payments made)
fetch('/payroll/batch-pay/preview/')
.then(function(resp) { return resp.json(); })
.then(function(data) {
while (body.firstChild) body.removeChild(body.firstChild);
// --- No eligible workers ---
if (data.eligible.length === 0) {
var noData = document.createElement('div');
noData.className = 'text-center py-4';
var icon = document.createElement('i');
icon.className = 'fas fa-check-circle fa-3x text-muted mb-3 d-block';
noData.appendChild(icon);
var msg = document.createElement('p');
msg.className = 'text-muted';
msg.textContent = 'No workers eligible for batch payment — no completed pay periods with unpaid work.';
noData.appendChild(msg);
body.appendChild(noData);
if (data.skipped.length > 0) {
body.appendChild(buildSkippedSection(data.skipped));
}
return;
}
// --- Summary header ---
var summary = document.createElement('div');
summary.className = 'alert alert-info d-flex justify-content-between align-items-center mb-3';
var leftSpan = document.createElement('span');
var strong = document.createElement('strong');
strong.textContent = data.worker_count + ' worker(s)';
leftSpan.appendChild(strong);
leftSpan.appendChild(document.createTextNode(' eligible for payment'));
summary.appendChild(leftSpan);
var rightSpan = document.createElement('span');
rightSpan.className = 'fw-bold';
rightSpan.textContent = 'Total: R ' + formatMoney(data.total_amount);
summary.appendChild(rightSpan);
body.appendChild(summary);
// --- Eligible workers table ---
var table = document.createElement('table');
table.className = 'table table-sm table-hover mb-3';
// Table header
var thead = document.createElement('thead');
var headerRow = document.createElement('tr');
var thCb = document.createElement('th');
thCb.style.width = '30px';
var selectAllCb = document.createElement('input');
selectAllCb.type = 'checkbox';
selectAllCb.id = 'batchSelectAll';
selectAllCb.checked = true;
thCb.appendChild(selectAllCb);
headerRow.appendChild(thCb);
['Worker', 'Team', 'Period'].forEach(function(text) {
var th = document.createElement('th');
th.textContent = text;
headerRow.appendChild(th);
});
['Days', 'Net Pay'].forEach(function(text) {
var th = document.createElement('th');
th.className = 'text-end';
th.textContent = text;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
// Table body
var tbody = document.createElement('tbody');
data.eligible.forEach(function(w, idx) {
tbody.appendChild(buildWorkerRow(w, idx));
});
table.appendChild(tbody);
body.appendChild(table);
// --- Select All checkbox behavior ---
selectAllCb.addEventListener('change', function() {
var cbs = body.querySelectorAll('.batch-worker-cb');
for (var i = 0; i < cbs.length; i++) {
cbs[i].checked = selectAllCb.checked;
}
updateBatchSummary(data, summary);
});
// Update summary when individual checkboxes change
body.addEventListener('change', function(e) {
if (e.target.classList.contains('batch-worker-cb')) {
updateBatchSummary(data, summary);
var allCbs = body.querySelectorAll('.batch-worker-cb');
var allChecked = true;
for (var i = 0; i < allCbs.length; i++) {
if (!allCbs[i].checked) { allChecked = false; break; }
}
selectAllCb.checked = allChecked;
}
});
// --- Skipped workers (collapsible) ---
if (data.skipped.length > 0) {
body.appendChild(buildSkippedSection(data.skipped));
}
// Store data for the confirm button
window._batchPayData = data.eligible;
// Show footer with Confirm button
footer.style.display = '';
// Reset confirm button state
var cBtn = document.getElementById('confirmBatchPayBtn');
cBtn.disabled = false;
while (cBtn.firstChild) cBtn.removeChild(cBtn.firstChild);
var btnIcon = document.createElement('i');
btnIcon.className = 'fas fa-money-bill-wave me-1';
cBtn.appendChild(btnIcon);
cBtn.appendChild(document.createTextNode('Confirm & Pay All'));
})
.catch(function() {
while (body.firstChild) body.removeChild(body.firstChild);
var errDiv = document.createElement('div');
errDiv.className = 'alert alert-danger';
errDiv.textContent = 'Failed to load batch preview. Please try again.';
body.appendChild(errDiv);
});
});
}
// --- Confirm & Pay All button ---
var confirmBatchBtn = document.getElementById('confirmBatchPayBtn');
if (confirmBatchBtn) {
confirmBatchBtn.addEventListener('click', function() {
var btn = this;
// Gather checked workers
var workers = [];
document.querySelectorAll('.batch-worker-cb:checked').forEach(function(cb) {
var idx = parseInt(cb.dataset.index);
var w = window._batchPayData[idx];
workers.push({
worker_id: w.worker_id,
log_ids: w.log_ids,
adj_ids: w.adj_ids,
});
});
if (workers.length === 0) {
alert('No workers selected. Check at least one worker to proceed.');
return;
}
// Disable button and show processing state
btn.disabled = true;
while (btn.firstChild) btn.removeChild(btn.firstChild);
var sp = document.createElement('span');
sp.className = 'spinner-border spinner-border-sm me-2';
sp.setAttribute('role', 'status');
btn.appendChild(sp);
btn.appendChild(document.createTextNode('Processing ' + workers.length + ' payment(s)...'));
// POST to batch pay endpoint
fetch('/payroll/batch-pay/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
},
body: JSON.stringify({ workers: workers }),
}).then(function() {
// Redirect to refresh page and show Django success messages
window.location.href = '/payroll/';
}).catch(function() {
btn.disabled = false;
while (btn.firstChild) btn.removeChild(btn.firstChild);
var retryIcon = document.createElement('i');
retryIcon.className = 'fas fa-money-bill-wave me-1';
btn.appendChild(retryIcon);
btn.appendChild(document.createTextNode('Confirm & Pay All'));
alert('Batch payment failed. Please try again.');
});
});
}
}); // end DOMContentLoaded
</script>

View File

@ -31,6 +31,10 @@ urlpatterns = [
# Process payment — pays a worker and links their unpaid logs + adjustments
path('payroll/pay/<int:worker_id>/', views.process_payment, name='process_payment'),
# Batch pay — preview which workers would be paid, then process all at once
path('payroll/batch-pay/preview/', views.batch_pay_preview, name='batch_pay_preview'),
path('payroll/batch-pay/', views.batch_pay, name='batch_pay'),
# Price overtime — creates Overtime adjustments from unpriced OT entries
path('payroll/price-overtime/', views.price_overtime, name='price_overtime'),

View File

@ -1167,46 +1167,28 @@ def payroll_dashboard(request):
# =============================================================================
# === PROCESS PAYMENT ===
# Creates a PayrollRecord for a worker, linking all their unpaid work logs
# and applying any pending adjustments. Handles loan repayment deductions.
# === SINGLE PAYMENT HELPER ===
# Core payment logic used by both individual payments and batch payments.
# Locks the worker row, creates a PayrollRecord, links logs/adjustments,
# and handles loan repayment deductions — all inside an atomic transaction.
# =============================================================================
@login_required
def process_payment(request, worker_id):
if request.method != 'POST':
return redirect('payroll_dashboard')
if not is_admin(request.user):
return HttpResponseForbidden("Not authorized.")
def _process_single_payment(worker_id, selected_log_ids=None, selected_adj_ids=None):
"""
Process payment for one worker inside an atomic transaction.
Returns (payroll_record, log_count, logs_amount) on success, or None if nothing to pay.
# Validate the worker exists first (returns 404 if not found)
worker = get_object_or_404(Worker, id=worker_id)
# --- DUPLICATE PAYMENT PREVENTION ---
# All queries and the PayrollRecord creation happen inside a single
# database transaction. select_for_update() locks the Worker row,
# which forces concurrent requests (e.g. double-click) to wait.
# The second request will see the logs as already paid and bail out.
- worker_id: the Worker's PK
- selected_log_ids: list of WorkLog IDs to include (None = all unpaid)
- selected_adj_ids: list of PayrollAdjustment IDs to include (None = all pending)
"""
with transaction.atomic():
# Lock this worker's row — any other request for the same worker
# will wait here until this transaction commits.
# Lock this worker's row — any concurrent request for the same
# worker will wait here until this transaction commits.
worker = Worker.objects.select_for_update().get(id=worker_id)
# === SPLIT PAYSLIP SUPPORT ===
# If the POST includes specific log/adjustment IDs (from the preview
# modal's checkboxes), only pay those selected items.
# If no IDs provided (e.g., the quick "Pay" button on the table),
# fall back to paying everything — backward compatible.
selected_log_ids = [int(x) for x in request.POST.getlist('selected_log_ids') if x.isdigit()]
selected_adj_ids = [int(x) for x in request.POST.getlist('selected_adj_ids') if x.isdigit()]
# Find unpaid logs for this worker (inside the lock, so this
# result is guaranteed to be up-to-date)
all_unpaid_logs = worker.work_logs.exclude(
payroll_records__worker=worker
)
# If specific logs were selected, only pay those
# Get unpaid logs, filter to selected if IDs provided
all_unpaid_logs = worker.work_logs.exclude(payroll_records__worker=worker)
if selected_log_ids:
unpaid_logs = all_unpaid_logs.filter(id__in=selected_log_ids)
else:
@ -1215,7 +1197,7 @@ def process_payment(request, worker_id):
log_count = unpaid_logs.count()
logs_amount = log_count * worker.daily_rate
# Find pending adjustments — filter to selected if IDs provided
# Get pending adjustments, filter to selected if IDs provided
all_pending_adjs = list(worker.adjustments.filter(payroll_record__isnull=True))
if selected_adj_ids:
selected_adj_set = set(selected_adj_ids)
@ -1223,11 +1205,9 @@ def process_payment(request, worker_id):
else:
pending_adjs = all_pending_adjs
# Nothing to pay — already paid or nothing owed
if log_count == 0 and not pending_adjs:
# Nothing to pay — either everything is already paid (duplicate
# request), or there genuinely are no pending items.
messages.warning(request, f'No pending payments for {worker.name} — already paid or nothing owed.')
return redirect('payroll_dashboard')
return None
# Calculate net adjustment
adj_amount = Decimal('0.00')
@ -1246,10 +1226,10 @@ def process_payment(request, worker_id):
date=timezone.now().date(),
)
# Link all unpaid work logs to this payment
# Link work logs to this payment
payroll_record.work_logs.set(unpaid_logs)
# Link all pending adjustments to this payment
# Link adjustments + handle loan repayments
for adj in pending_adjs:
adj.payroll_record = payroll_record
adj.save()
@ -1268,6 +1248,45 @@ def process_payment(request, worker_id):
adj.loan.loan_type = 'loan'
adj.loan.save()
return (payroll_record, log_count, logs_amount)
# =============================================================================
# === PROCESS PAYMENT ===
# HTTP endpoint for paying a single worker. Reads selected IDs from the POST
# form (split payslip), delegates to _process_single_payment, then emails.
# =============================================================================
@login_required
def process_payment(request, worker_id):
if request.method != 'POST':
return redirect('payroll_dashboard')
if not is_admin(request.user):
return HttpResponseForbidden("Not authorized.")
# Validate the worker exists (returns 404 if not found)
worker = get_object_or_404(Worker, id=worker_id)
# === SPLIT PAYSLIP SUPPORT ===
# If the POST includes specific log/adjustment IDs (from the preview
# modal's checkboxes), only pay those selected items.
# If no IDs provided (e.g., the quick "Pay" button on the table),
# fall back to paying everything — backward compatible.
selected_log_ids = [int(x) for x in request.POST.getlist('selected_log_ids') if x.isdigit()]
selected_adj_ids = [int(x) for x in request.POST.getlist('selected_adj_ids') if x.isdigit()]
result = _process_single_payment(
worker_id,
selected_log_ids=selected_log_ids or None,
selected_adj_ids=selected_adj_ids or None,
)
if result is None:
messages.warning(request, f'No pending payments for {worker.name} — already paid or nothing owed.')
return redirect('payroll_dashboard')
payroll_record, log_count, logs_amount = result
# =========================================================================
# EMAIL PAYSLIP (outside the transaction — if email fails, payment is
# still saved. We don't want a network error to roll back a real payment.)
@ -1283,7 +1302,7 @@ def process_payment(request, worker_id):
# Used by both process_payment (regular salary) and add_adjustment (advances).
# =============================================================================
def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount):
def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount, suppress_messages=False):
"""
Generate and email a payslip for a completed payment.
Called after a PayrollRecord has been created and adjustments linked.
@ -1293,6 +1312,7 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount)
- payroll_record: the PayrollRecord just created
- log_count: number of work logs in this payment (0 for advance-only)
- logs_amount: total earnings from work logs (Decimal('0.00') for advance-only)
- suppress_messages: if True, skip Django messages (used by batch pay)
"""
# Lazy import — avoids crashing the app if xhtml2pdf isn't installed
from .utils import render_to_pdf
@ -1351,26 +1371,230 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount)
)
email.send()
if not suppress_messages:
messages.success(
request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
f'Payslip emailed successfully.'
)
except Exception as e:
# Payment is saved — just warn that email failed
if not suppress_messages:
messages.warning(
request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}, '
f'but email delivery failed: {str(e)}'
)
raise # Re-raise so batch_pay can count failures
else:
# No SPARK_RECEIPT_EMAIL configured — just show success
if not suppress_messages:
messages.success(
request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
f'Payslip emailed successfully.'
f'{log_count} work log(s) marked as paid.'
)
except Exception as e:
# Payment is saved — just warn that email failed
messages.warning(
request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}, '
f'but email delivery failed: {str(e)}'
)
else:
# No SPARK_RECEIPT_EMAIL configured — just show success
messages.success(
request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
f'{log_count} work log(s) marked as paid.'
# =============================================================================
# === BATCH PAY PREVIEW ===
# AJAX GET endpoint — dry run showing which workers would be paid and how
# much, based on their team's pay schedule. No payments are made here.
# =============================================================================
@login_required
def batch_pay_preview(request):
"""Return JSON preview of batch payment — who gets paid and how much."""
if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403)
eligible = []
skipped = []
total_amount = Decimal('0.00')
# Get all active workers with their work logs and pending adjustments
active_workers = Worker.objects.filter(active=True).prefetch_related(
Prefetch(
'work_logs',
queryset=WorkLog.objects.prefetch_related('payroll_records').select_related('project')
),
Prefetch(
'adjustments',
queryset=PayrollAdjustment.objects.filter(payroll_record__isnull=True)
),
).order_by('name')
for worker in active_workers:
# --- Check if worker has a team with a pay schedule ---
team = get_worker_active_team(worker)
if not team or not team.pay_frequency or not team.pay_start_date:
# Check if worker has ANY unpaid items before listing as skipped
has_unpaid = False
for log in worker.work_logs.all():
paid_ids = {pr.worker_id for pr in log.payroll_records.all()}
if worker.id not in paid_ids:
has_unpaid = True
break
if not has_unpaid:
has_unpaid = worker.adjustments.filter(payroll_record__isnull=True).exists()
if has_unpaid:
skipped.append({
'worker_name': worker.name,
'reason': 'No pay schedule configured',
})
continue
# --- Get the current pay period and calculate the cutoff date ---
# cutoff_date = end of the last COMPLETED period.
# We pay ALL overdue work (across all past periods), not just one period.
period_start, period_end = get_pay_period(team)
if not period_start:
continue
cutoff_date = period_start - datetime.timedelta(days=1)
# --- Find unpaid logs up to the cutoff date ---
unpaid_log_ids = []
for log in worker.work_logs.all():
paid_ids = {pr.worker_id for pr in log.payroll_records.all()}
if worker.id not in paid_ids and log.date <= cutoff_date:
unpaid_log_ids.append(log.id)
# --- Find pending adjustments up to the cutoff date ---
unpaid_adj_ids = []
adj_amount = Decimal('0.00')
for adj in worker.adjustments.all():
if adj.date and adj.date <= cutoff_date:
unpaid_adj_ids.append(adj.id)
if adj.type in ADDITIVE_TYPES:
adj_amount += adj.amount
elif adj.type in DEDUCTIVE_TYPES:
adj_amount -= adj.amount
# Nothing due for this worker (all work is in the current, incomplete period)
if not unpaid_log_ids and not unpaid_adj_ids:
continue
log_count = len(unpaid_log_ids)
logs_amount = log_count * worker.daily_rate
net = logs_amount + adj_amount
# Skip workers with zero or negative net pay
if net <= 0:
skipped.append({
'worker_name': worker.name,
'reason': f'Net pay is R {net:,.2f} (zero or negative)',
})
continue
# Format: show "up to [cutoff date]" since we're paying all overdue work
# Use day integer to avoid platform-specific strftime issues (%-d on Linux, %#d on Windows)
period_display = f"Up to {cutoff_date.day} {cutoff_date.strftime('%b %Y')}"
eligible.append({
'worker_id': worker.id,
'worker_name': worker.name,
'team_name': team.name,
'period': period_display,
'days': log_count,
'logs_amount': float(logs_amount),
'adj_amount': float(adj_amount),
'net_pay': float(net),
'log_ids': unpaid_log_ids,
'adj_ids': unpaid_adj_ids,
})
total_amount += net
return JsonResponse({
'eligible': eligible,
'skipped': skipped,
'total_amount': float(total_amount),
'worker_count': len(eligible),
})
# =============================================================================
# === BATCH PAY (PROCESS) ===
# POST endpoint — processes payments for multiple workers at once.
# Each worker gets their own atomic transaction and payslip email.
# =============================================================================
@login_required
def batch_pay(request):
"""Process batch payments for multiple workers using their team pay schedules."""
if request.method != 'POST':
return redirect('payroll_dashboard')
if not is_admin(request.user):
return HttpResponseForbidden("Not authorized.")
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
messages.error(request, 'Invalid request data.')
return redirect('payroll_dashboard')
workers_to_pay = body.get('workers', [])
if not workers_to_pay:
messages.warning(request, 'No workers selected for batch payment.')
return redirect('payroll_dashboard')
# === PROCESS EACH WORKER ===
# Each worker gets their own atomic transaction (independent row locks).
# This means if one worker fails, others still succeed.
paid_count = 0
paid_total = Decimal('0.00')
errors = []
email_queue = [] # Collect payslip data for emails (sent after all payments)
for entry in workers_to_pay:
worker_id = entry.get('worker_id')
log_ids = entry.get('log_ids', [])
adj_ids = entry.get('adj_ids', [])
try:
worker = Worker.objects.get(id=worker_id, active=True)
except Worker.DoesNotExist:
errors.append(f'Worker ID {worker_id} not found or inactive.')
continue
result = _process_single_payment(
worker_id,
selected_log_ids=log_ids or None,
selected_adj_ids=adj_ids or None,
)
if result is None:
continue # Nothing to pay — silently skip
payroll_record, log_count, logs_amount = result
paid_count += 1
paid_total += payroll_record.amount_paid
email_queue.append((worker, payroll_record, log_count, logs_amount))
# === SEND PAYSLIP EMAILS (outside all transactions) ===
# If an email fails, the payment is still saved — same pattern as individual pay.
email_failures = 0
for worker, pr, lc, la in email_queue:
try:
_send_payslip_email(request, worker, pr, lc, la, suppress_messages=True)
except Exception:
email_failures += 1
# === SUMMARY MESSAGE ===
if paid_count > 0:
msg = f'Batch payment complete: {paid_count} worker(s) paid, total R {paid_total:,.2f}.'
if email_failures:
msg += f' ({email_failures} email(s) failed to send.)'
messages.success(request, msg)
for err in errors:
messages.warning(request, err)
if paid_count == 0 and not errors:
messages.info(request, 'No payments were processed — all workers already paid or had zero/negative net pay.')
return redirect('payroll_dashboard')
# =============================================================================
# === PRICE OVERTIME ===
@ -1810,10 +2034,18 @@ def preview_payslip(request, worker_id):
# current period boundaries so the "Split at Pay Date" button can work.
team = get_worker_active_team(worker)
period_start, period_end = get_pay_period(team)
# cutoff_date = last day of the most recently COMPLETED pay period.
# All unpaid logs on or before this date are "due" for payment.
# E.g., fortnightly periods ending Mar 14, Mar 28, Apr 11...
# If today is Mar 20, cutoff_date = Mar 14 (pay everything through last completed period).
cutoff_date = (period_start - datetime.timedelta(days=1)) if period_start else None
pay_period = {
'has_schedule': period_start is not None,
'start': period_start.strftime('%Y-%m-%d') if period_start else None,
'end': period_end.strftime('%Y-%m-%d') if period_end else None,
'cutoff_date': cutoff_date.strftime('%Y-%m-%d') if cutoff_date else None,
'frequency': team.pay_frequency if team else None,
'team_name': team.name if team else None,
}