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:
parent
0257b454af
commit
d51d06d28d
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "cmd",
|
||||
"runtimeArgs": ["/c", "run_dev.bat"],
|
||||
"port": 8000
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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 |
|
||||
|
||||
@ -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.
|
||||
|
||||
120
core/views.py
120
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user