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"> <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> <h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Payroll Dashboard</h1>
<div class="d-flex gap-2"> <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"> <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 <i class="fas fa-plus fa-sm me-1"></i> Add Adjustment
</button> </button>
@ -678,6 +681,33 @@
</div> </div>
{# --- PREVIEW PAYSLIP MODAL --- #} {# --- 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 fade" id="previewPayslipModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
@ -1462,10 +1492,13 @@ document.addEventListener('DOMContentLoaded', function() {
var infoIcon = document.createElement('i'); var infoIcon = document.createElement('i');
infoIcon.className = 'fas fa-calendar-alt me-2'; infoIcon.className = 'fas fa-calendar-alt me-2';
periodInfo.appendChild(infoIcon); 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( periodInfo.appendChild(document.createTextNode(
data.pay_period.team_name + ' \u2014 ' + data.pay_period.team_name + ' \u2014 ' +
data.pay_period.frequency + ' pay period: ' + data.pay_period.frequency + ' | Current period: ' +
data.pay_period.start + ' to ' + data.pay_period.end data.pay_period.start + ' to ' + data.pay_period.end +
' | Pay through: ' + data.pay_period.cutoff_date
)); ));
modalBody.appendChild(periodInfo); modalBody.appendChild(periodInfo);
@ -1476,17 +1509,20 @@ document.addEventListener('DOMContentLoaded', function() {
splitIcon.className = 'fas fa-cut me-1'; splitIcon.className = 'fas fa-cut me-1';
splitBtn.appendChild(splitIcon); splitBtn.appendChild(splitIcon);
splitBtn.appendChild(document.createTextNode( 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() { splitBtn.addEventListener('click', function() {
var periodEnd = data.pay_period.end; // Use cutoff_date (end of last completed period) — includes ALL
// Uncheck logs after the pay period end date // 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) { 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) { modalBody.querySelectorAll('.adj-checkbox').forEach(function(cb) {
cb.checked = (cb.dataset.adjDate <= periodEnd); cb.checked = (cb.dataset.adjDate <= cutoff);
}); });
recalcNetPay(); 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 }); // end DOMContentLoaded
</script> </script>

View File

@ -31,6 +31,10 @@ urlpatterns = [
# Process payment — pays a worker and links their unpaid logs + adjustments # Process payment — pays a worker and links their unpaid logs + adjustments
path('payroll/pay/<int:worker_id>/', views.process_payment, name='process_payment'), 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 # Price overtime — creates Overtime adjustments from unpriced OT entries
path('payroll/price-overtime/', views.price_overtime, name='price_overtime'), path('payroll/price-overtime/', views.price_overtime, name='price_overtime'),

View File

@ -1167,46 +1167,28 @@ def payroll_dashboard(request):
# ============================================================================= # =============================================================================
# === PROCESS PAYMENT === # === SINGLE PAYMENT HELPER ===
# Creates a PayrollRecord for a worker, linking all their unpaid work logs # Core payment logic used by both individual payments and batch payments.
# and applying any pending adjustments. Handles loan repayment deductions. # Locks the worker row, creates a PayrollRecord, links logs/adjustments,
# and handles loan repayment deductions — all inside an atomic transaction.
# ============================================================================= # =============================================================================
@login_required def _process_single_payment(worker_id, selected_log_ids=None, selected_adj_ids=None):
def process_payment(request, worker_id): """
if request.method != 'POST': Process payment for one worker inside an atomic transaction.
return redirect('payroll_dashboard') Returns (payroll_record, log_count, logs_amount) on success, or None if nothing to pay.
if not is_admin(request.user):
return HttpResponseForbidden("Not authorized.")
# Validate the worker exists first (returns 404 if not found) - worker_id: the Worker's PK
worker = get_object_or_404(Worker, id=worker_id) - selected_log_ids: list of WorkLog IDs to include (None = all unpaid)
- selected_adj_ids: list of PayrollAdjustment IDs to include (None = all pending)
# --- 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.
with transaction.atomic(): with transaction.atomic():
# Lock this worker's row — any other request for the same worker # Lock this worker's row — any concurrent request for the same
# will wait here until this transaction commits. # worker will wait here until this transaction commits.
worker = Worker.objects.select_for_update().get(id=worker_id) worker = Worker.objects.select_for_update().get(id=worker_id)
# === SPLIT PAYSLIP SUPPORT === # Get unpaid logs, filter to selected if IDs provided
# If the POST includes specific log/adjustment IDs (from the preview all_unpaid_logs = worker.work_logs.exclude(payroll_records__worker=worker)
# 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
if selected_log_ids: if selected_log_ids:
unpaid_logs = all_unpaid_logs.filter(id__in=selected_log_ids) unpaid_logs = all_unpaid_logs.filter(id__in=selected_log_ids)
else: else:
@ -1215,7 +1197,7 @@ def process_payment(request, worker_id):
log_count = unpaid_logs.count() log_count = unpaid_logs.count()
logs_amount = log_count * worker.daily_rate 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)) all_pending_adjs = list(worker.adjustments.filter(payroll_record__isnull=True))
if selected_adj_ids: if selected_adj_ids:
selected_adj_set = set(selected_adj_ids) selected_adj_set = set(selected_adj_ids)
@ -1223,11 +1205,9 @@ def process_payment(request, worker_id):
else: else:
pending_adjs = all_pending_adjs pending_adjs = all_pending_adjs
# Nothing to pay — already paid or nothing owed
if log_count == 0 and not pending_adjs: if log_count == 0 and not pending_adjs:
# Nothing to pay — either everything is already paid (duplicate return None
# 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')
# Calculate net adjustment # Calculate net adjustment
adj_amount = Decimal('0.00') adj_amount = Decimal('0.00')
@ -1246,10 +1226,10 @@ def process_payment(request, worker_id):
date=timezone.now().date(), 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) payroll_record.work_logs.set(unpaid_logs)
# Link all pending adjustments to this payment # Link adjustments + handle loan repayments
for adj in pending_adjs: for adj in pending_adjs:
adj.payroll_record = payroll_record adj.payroll_record = payroll_record
adj.save() adj.save()
@ -1268,6 +1248,45 @@ def process_payment(request, worker_id):
adj.loan.loan_type = 'loan' adj.loan.loan_type = 'loan'
adj.loan.save() 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 # 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.) # 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). # 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. Generate and email a payslip for a completed payment.
Called after a PayrollRecord has been created and adjustments linked. 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 - payroll_record: the PayrollRecord just created
- log_count: number of work logs in this payment (0 for advance-only) - 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) - 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 # Lazy import — avoids crashing the app if xhtml2pdf isn't installed
from .utils import render_to_pdf from .utils import render_to_pdf
@ -1351,26 +1371,230 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount)
) )
email.send() 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( messages.success(
request, request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}. ' 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, # === BATCH PAY PREVIEW ===
f'Payment of R {total_amount:,.2f} processed for {worker.name}, ' # AJAX GET endpoint — dry run showing which workers would be paid and how
f'but email delivery failed: {str(e)}' # much, based on their team's pay schedule. No payments are made here.
) # =============================================================================
else:
# No SPARK_RECEIPT_EMAIL configured — just show success @login_required
messages.success( def batch_pay_preview(request):
request, """Return JSON preview of batch payment — who gets paid and how much."""
f'Payment of R {total_amount:,.2f} processed for {worker.name}. ' if not is_admin(request.user):
f'{log_count} work log(s) marked as paid.' 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 === # === PRICE OVERTIME ===
@ -1810,10 +2034,18 @@ def preview_payslip(request, worker_id):
# current period boundaries so the "Split at Pay Date" button can work. # current period boundaries so the "Split at Pay Date" button can work.
team = get_worker_active_team(worker) team = get_worker_active_team(worker)
period_start, period_end = get_pay_period(team) 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 = { pay_period = {
'has_schedule': period_start is not None, 'has_schedule': period_start is not None,
'start': period_start.strftime('%Y-%m-%d') if period_start else 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, '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, 'frequency': team.pay_frequency if team else None,
'team_name': team.name if team else None, 'team_name': team.name if team else None,
} }