From 0fa25e153838ec0512e5dda470af5d00ff0edcb3 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Sun, 22 Feb 2026 23:06:31 +0200 Subject: [PATCH] Prevent duplicate payslip emails from double-click on Pay button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Supervisors on slow mobile connections sometimes double-click the "Pay" button, causing two PayrollRecords + two payslip emails to be sent to Spark Receipt for the same worker. Backend fix (the critical part): - Moved unpaid_logs and pending_adjs queries INSIDE transaction.atomic() - Added select_for_update() on Worker row — this database-level lock forces the second concurrent request to WAIT until the first commits - After the lock is acquired, the second request re-queries and finds no unpaid logs (already paid by first request), so it bails out Frontend fix (defence-in-depth): - Pay button now shows a Bootstrap spinner + "Processing..." text - Second click is blocked with e.preventDefault() if button is already disabled (handles edge case where form resubmits) Co-Authored-By: Claude Opus 4.6 --- core/templates/core/payroll_dashboard.html | 22 ++++++-- core/views.py | 61 +++++++++++++--------- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index 1a20f2e..5b3f009 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -1162,13 +1162,27 @@ document.addEventListener('DOMContentLoaded', function() { }); // ================================================================= - // PAY FORM — Disable button after click to prevent double-submission + // PAY FORM — Disable button after click to prevent double-submission. + // Shows a spinner so the user knows the payment is being processed. + // Backend also has select_for_update() as a safety net in case the + // frontend disable fails (slow JS on mobile, etc.). // ================================================================= document.querySelectorAll('.pay-form').forEach(function(form) { - form.addEventListener('submit', function() { - const btn = this.querySelector('button[type="submit"]'); + form.addEventListener('submit', function(e) { + var btn = this.querySelector('button[type="submit"]'); + // If already disabled, block the second submit entirely + if (btn.disabled) { + e.preventDefault(); + return false; + } btn.disabled = true; - btn.textContent = 'Processing...'; + // Replace button content with a spinner + "Processing..." + while (btn.firstChild) btn.removeChild(btn.firstChild); + var spinner = document.createElement('span'); + spinner.className = 'spinner-border spinner-border-sm me-2'; + spinner.setAttribute('role', 'status'); + btn.appendChild(spinner); + btn.appendChild(document.createTextNode('Processing...')); }); }); diff --git a/core/views.py b/core/views.py index f96a455..6d5050d 100644 --- a/core/views.py +++ b/core/views.py @@ -901,33 +901,46 @@ def process_payment(request, worker_id): if not is_admin(request.user): return HttpResponseForbidden("Not authorized.") + # Validate the worker exists first (returns 404 if not found) worker = get_object_or_404(Worker, id=worker_id) - # Find unpaid logs for this worker - unpaid_logs = worker.work_logs.exclude( - payroll_records__worker=worker - ) - log_count = unpaid_logs.count() - logs_amount = log_count * worker.daily_rate - - # Find pending adjustments - pending_adjs = worker.adjustments.filter(payroll_record__isnull=True) - - if log_count == 0 and not pending_adjs.exists(): - messages.warning(request, f'No pending payments for {worker.name}.') - return redirect('payroll_dashboard') - - # Calculate net adjustment - adj_amount = Decimal('0.00') - for adj in pending_adjs: - if adj.type in ADDITIVE_TYPES: - adj_amount += adj.amount - elif adj.type in DEDUCTIVE_TYPES: - adj_amount -= adj.amount - - total_amount = logs_amount + adj_amount - + # --- 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(): + # Lock this worker's row — any other request for the same worker + # will wait here until this transaction commits. + worker = Worker.objects.select_for_update().get(id=worker_id) + + # Find unpaid logs for this worker (inside the lock, so this + # result is guaranteed to be up-to-date) + unpaid_logs = worker.work_logs.exclude( + payroll_records__worker=worker + ) + log_count = unpaid_logs.count() + logs_amount = log_count * worker.daily_rate + + # Find pending adjustments + pending_adjs = list(worker.adjustments.filter(payroll_record__isnull=True)) + + 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') + + # Calculate net adjustment + adj_amount = Decimal('0.00') + for adj in pending_adjs: + if adj.type in ADDITIVE_TYPES: + adj_amount += adj.amount + elif adj.type in DEDUCTIVE_TYPES: + adj_amount -= adj.amount + + total_amount = logs_amount + adj_amount + # Create the PayrollRecord payroll_record = PayrollRecord.objects.create( worker=worker,