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 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-03-05 14:23:03 +02:00
parent 0257b454af
commit d51d06d28d
4 changed files with 145 additions and 13 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "cmd",
"runtimeArgs": ["/c", "run_dev.bat"],
"port": 8000
}
]
}

View File

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

View File

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

View File

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