Prevent duplicate payslip emails from double-click on Pay button

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 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-22 23:06:31 +02:00
parent f9423c0b3e
commit 0fa25e1538
2 changed files with 55 additions and 28 deletions

View File

@ -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...'));
});
});

View File

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