From d51d06d28d9a9c04109659e9fe0c6736fb0341f2 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 5 Mar 2026 14:23:03 +0200 Subject: [PATCH] Redesign advance payments: auto-process immediately with auto-repayment Advances are now treated as immediate payments (not pending salary items): - Auto-creates PayrollRecord + sends payslip email at creation time - Auto-creates Advance Repayment adjustment for next salary cycle - Validates worker has unpaid work logs (otherwise use New Loan) - Requires project selection for cost tracking - Partial repayment converts advance to regular loan - Admin can edit auto-repayment amount before payday - Negative net pay warning in preview modal Co-Authored-By: Claude Opus 4.6 --- .claude/launch.json | 11 ++ CLAUDE.md | 6 +- core/templates/core/payroll_dashboard.html | 21 +++- core/views.py | 120 +++++++++++++++++++-- 4 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..c41efff --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "cmd", + "runtimeArgs": ["/c", "run_dev.bat"], + "port": 8000 + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index c9cacfd..4d15133 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,10 +64,10 @@ Defined at top of views.py — used in dashboard calculations and payment proces ## PayrollAdjustment Type Handling - **Bonus / Deduction** — standalone, require a linked Project - **New Loan** — creates a `Loan` record (`loan_type='loan'`); editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments -- **Advance Payment** — creates a `Loan` record (`loan_type='advance'`); works exactly like New Loan but tagged as "advance" in the Loans tab. Worker receives money upfront (additive). +- **Advance Payment** — **auto-processed immediately** (never sits in Pending): creates `Loan` (`loan_type='advance'`), creates PayrollRecord, sends payslip email, and auto-creates an "Advance Repayment" for the next salary. Requires a Project (for cost tracking) and at least one unpaid work log (otherwise use New Loan). - **Overtime** — links to `WorkLog` via `adj.work_log` FK; managed by `price_overtime()` view - **Loan Repayment** — links to `Loan` (loan_type='loan') via `adj.loan` FK; loan balance changes during payment processing -- **Advance Repayment** — links to `Loan` (loan_type='advance') via `adj.loan` FK; deducts from advance balance during payment processing +- **Advance Repayment** — auto-created when an advance is paid; deducts from advance balance during `process_payment()`. If partial repayment, remaining balance converts advance to regular loan (`loan_type` changes from 'advance' to 'loan'). Editable by admin (amount can be reduced before payday). ## Outstanding Payments Logic (Dashboard) The dashboard's outstanding amount uses **per-worker** checking, not per-log: @@ -100,6 +100,8 @@ python manage.py check # System check - Template UI: Bootstrap 5 modals with dynamic JS, data-* attributes on clickable elements - Atomic transactions: `process_payment()` uses `select_for_update()` to prevent duplicate payments - Payslip Preview modal ("Worker Payment Hub"): shows earnings, adjustments, net pay, **plus** active loans/advances with inline repayment forms. Uses `refreshPreview()` JS function that re-fetches after AJAX repayment submission. Repayment POSTs to `add_repayment_ajax` which creates a PayrollAdjustment (balance deduction only happens during `process_payment()`) +- Advance Payment auto-processing: `add_adjustment` immediately creates PayrollRecord + sends payslip when an advance is created. Also auto-creates an "Advance Repayment" adjustment for the next salary cycle. Uses `_send_payslip_email()` helper (shared with `process_payment`) +- Advance-to-loan conversion: When an Advance Repayment is only partially paid, `process_payment` changes the Loan's `loan_type` from 'advance' to 'loan' so the remainder is tracked as a regular loan ## URL Routes | Path | View | Purpose | diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index f5ebd40..c819866 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -1172,8 +1172,9 @@ document.addEventListener('DOMContentLoaded', function() { // only enforces the project when the type actually needs one. function toggleProjectField() { if (!addAdjType || !addAdjProjectGroup) return; - // Loan types, repayments, and advances don't need a project - var noProjectTypes = ['New Loan', 'Loan Repayment', 'Advance Payment', 'Advance Repayment']; + // Loan types and repayments don't need a project. + // Advance Payment DOES need a project (for cost tracking). + var noProjectTypes = ['New Loan', 'Loan Repayment', 'Advance Repayment']; var needsProject = noProjectTypes.indexOf(addAdjType.value) === -1; addAdjProjectGroup.style.display = needsProject ? '' : 'none'; if (addAdjProject) { @@ -1430,6 +1431,22 @@ document.addEventListener('DOMContentLoaded', function() { netRow.appendChild(netVal); modalBody.appendChild(netRow); + // === NEGATIVE NET PAY WARNING === + // If net pay is negative (e.g., advance repayment exceeds earnings), + // show a warning so the admin can edit the repayment before paying. + if (data.net_pay < 0) { + var warnDiv = document.createElement('div'); + warnDiv.className = 'alert alert-warning py-2 px-3 small mt-2 mb-0'; + var warnIcon = document.createElement('i'); + warnIcon.className = 'fas fa-exclamation-triangle me-2'; + warnDiv.appendChild(warnIcon); + warnDiv.appendChild(document.createTextNode( + 'Net pay is negative. Consider editing the Advance Repayment ' + + 'amount on the Pending Payments table before processing.' + )); + modalBody.appendChild(warnDiv); + } + // ============================================================= // OUTSTANDING LOANS & ADVANCES — shows active balances with // inline repayment forms so the admin can deduct right here. diff --git a/core/views.py b/core/views.py index b14d6b8..81fc200 100644 --- a/core/views.py +++ b/core/views.py @@ -1144,16 +1144,45 @@ def process_payment(request, worker_id): if adj.loan.remaining_balance <= 0: adj.loan.remaining_balance = Decimal('0.00') adj.loan.active = False + # === ADVANCE-TO-LOAN CONVERSION === + # If an advance was only partially repaid, the remainder is + # now a regular loan. Change the type so it shows under + # "Loans" in the Loans tab and uses "Loan Repayment" going forward. + elif adj.type == 'Advance Repayment' and adj.loan.loan_type == 'advance': + adj.loan.loan_type = 'loan' adj.loan.save() # ========================================================================= # 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.) # ========================================================================= + _send_payslip_email(request, worker, payroll_record, log_count, logs_amount) + return redirect('payroll_dashboard') + + +# ============================================================================= +# === PAYSLIP EMAIL HELPER === +# Generates and sends a payslip (HTML email + PDF attachment). +# Used by both process_payment (regular salary) and add_adjustment (advances). +# ============================================================================= + +def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount): + """ + Generate and email a payslip for a completed payment. + Called after a PayrollRecord has been created and adjustments linked. + + - request: Django request (for messages framework) + - worker: the Worker being paid + - payroll_record: the PayrollRecord just created + - 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) + """ # Lazy import — avoids crashing the app if xhtml2pdf isn't installed from .utils import render_to_pdf + total_amount = payroll_record.amount_paid + # === DETECT ADVANCE-ONLY PAYMENT === # If the payment has 0 work logs and consists of only an Advance Payment # adjustment, use the special advance payslip layout (shows the advance @@ -1226,8 +1255,6 @@ def process_payment(request, worker_id): f'{log_count} work log(s) marked as paid.' ) - return redirect('payroll_dashboard') - # ============================================================================= # === PRICE OVERTIME === @@ -1326,7 +1353,7 @@ def add_adjustment(request): except Project.DoesNotExist: pass - project_required_types = ('Overtime', 'Bonus', 'Deduction') + project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment') if adj_type in project_required_types and not project: messages.error(request, 'A project must be selected for this adjustment type.') return redirect('payroll_dashboard') @@ -1365,10 +1392,32 @@ def add_adjustment(request): reason=description, ) - # === ADVANCE PAYMENT — create a Loan record (loan_type='advance') === - # Works just like New Loan but tagged as 'advance' so it shows - # separately in the Loans tab and uses "Advance Repayment" to deduct. + # === ADVANCE PAYMENT — immediate payment + auto-repayment === + # An advance is a salary prepayment — worker gets money now, and + # the full amount is automatically deducted from their next salary. + # Unlike other adjustments, advances are processed IMMEDIATELY + # (they don't sit in Pending Payments waiting for a "Pay" click). if adj_type == 'Advance Payment': + # VALIDATION: Worker must have unpaid work to justify an advance. + # If they have no logged work, this is a loan, not an advance. + has_unpaid_logs = False + for log in worker.work_logs.all(): + paid_worker_ids = set( + log.payroll_records.values_list('worker_id', flat=True) + ) + if worker.id not in paid_worker_ids: + has_unpaid_logs = True + break + + if not has_unpaid_logs: + messages.warning( + request, + f'{worker.name} has no unpaid work days — cannot create ' + f'an advance. Use "New Loan" instead.' + ) + continue + + # 1. Create the Loan record (tracks the advance balance) loan = Loan.objects.create( worker=worker, loan_type='advance', @@ -1378,6 +1427,46 @@ def add_adjustment(request): reason=description or 'Salary advance', ) + # 2. Create the Advance Payment adjustment + advance_adj = PayrollAdjustment.objects.create( + worker=worker, + type='Advance Payment', + amount=amount, + date=adj_date, + description=description, + project=project, + loan=loan, + ) + + # 3. AUTO-PROCESS: Create PayrollRecord immediately + # (advance is paid now, not at the next payday) + payroll_record = PayrollRecord.objects.create( + worker=worker, + amount_paid=amount, + date=adj_date, + ) + advance_adj.payroll_record = payroll_record + advance_adj.save() + + # 4. AUTO-CREATE REPAYMENT for the next salary cycle + # This ensures the advance is automatically deducted from + # the worker's next salary without the admin having to remember. + PayrollAdjustment.objects.create( + worker=worker, + type='Advance Repayment', + amount=amount, + date=adj_date, + description=f'Auto-deduction for advance of R {amount:.2f}', + loan=loan, + project=project, + ) + + # 5. Send payslip email to SparkReceipt + _send_payslip_email(request, worker, payroll_record, 0, Decimal('0.00')) + created_count += 1 + continue # Skip the generic PayrollAdjustment creation below + + # === ALL OTHER TYPES — create a pending adjustment === PayrollAdjustment.objects.create( worker=worker, type=adj_type, @@ -1413,9 +1502,11 @@ def edit_adjustment(request, adj_id): messages.error(request, 'Cannot edit a paid adjustment.') return redirect('payroll_dashboard') - # Can't edit repayment adjustments (managed by the loan system) - if adj.type in ('Loan Repayment', 'Advance Repayment'): - messages.warning(request, 'Repayment adjustments cannot be edited directly.') + # Can't edit Loan Repayment adjustments (managed by the loan system). + # Advance Repayments CAN be edited — the admin may want to reduce the + # auto-deduction amount (e.g., deduct R50 of a R100 advance this payday). + if adj.type == 'Loan Repayment': + messages.warning(request, 'Loan repayment adjustments cannot be edited directly.') return redirect('payroll_dashboard') # Update fields @@ -1448,6 +1539,17 @@ def edit_adjustment(request, adj_id): else: adj.project = None + # === ADVANCE REPAYMENT EDIT — cap amount at loan balance === + # If the admin edits an auto-created advance repayment, make sure + # the amount doesn't exceed the loan's remaining balance. + if adj.type == 'Advance Repayment' and adj.loan: + if adj.amount > adj.loan.remaining_balance: + adj.amount = adj.loan.remaining_balance + messages.info( + request, + f'Amount capped at loan balance of R {adj.loan.remaining_balance:.2f}.' + ) + adj.save() # If it's a Loan or Advance adjustment, sync the loan details