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:
parent
f9423c0b3e
commit
0fa25e1538
@ -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...'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user