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) {
|
document.querySelectorAll('.pay-form').forEach(function(form) {
|
||||||
form.addEventListener('submit', function() {
|
form.addEventListener('submit', function(e) {
|
||||||
const btn = this.querySelector('button[type="submit"]');
|
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.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):
|
if not is_admin(request.user):
|
||||||
return HttpResponseForbidden("Not authorized.")
|
return HttpResponseForbidden("Not authorized.")
|
||||||
|
|
||||||
|
# Validate the worker exists first (returns 404 if not found)
|
||||||
worker = get_object_or_404(Worker, id=worker_id)
|
worker = get_object_or_404(Worker, id=worker_id)
|
||||||
|
|
||||||
# Find unpaid logs for this worker
|
# --- DUPLICATE PAYMENT PREVENTION ---
|
||||||
unpaid_logs = worker.work_logs.exclude(
|
# All queries and the PayrollRecord creation happen inside a single
|
||||||
payroll_records__worker=worker
|
# database transaction. select_for_update() locks the Worker row,
|
||||||
)
|
# which forces concurrent requests (e.g. double-click) to wait.
|
||||||
log_count = unpaid_logs.count()
|
# The second request will see the logs as already paid and bail out.
|
||||||
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
|
|
||||||
|
|
||||||
with transaction.atomic():
|
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
|
# Create the PayrollRecord
|
||||||
payroll_record = PayrollRecord.objects.create(
|
payroll_record = PayrollRecord.objects.create(
|
||||||
worker=worker,
|
worker=worker,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user