Add 'Pay Immediately' option for New Loan adjustments

When creating a New Loan, a "Pay Immediately" checkbox (checked by
default) processes the loan right away — creates PayrollRecord, sends
payslip to Spark, and records the loan as paid. Unchecking it keeps
the old behavior where the loan sits in Pending Payments.

Also adds loan-only payslip detection (like advance-only) across all
payslip views: email template, PDF template, and browser detail page
show a clean "Loan Payslip" layout instead of "0 days worked".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-03-25 09:59:42 +02:00
parent 72d40971f1
commit 66fab12b90
5 changed files with 130 additions and 17 deletions

View File

@ -29,7 +29,7 @@
<div class="header">
<div class="sub-header">PAYMENT TO BENEFICIARY</div>
<div class="beneficiary-name">{{ record.worker.name }}</div>
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% elif is_loan %}Loan Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
</div>
<!-- Beneficiary details -->
@ -53,6 +53,11 @@
<td>Advance Payment: {{ advance_description }}</td>
<td style="text-align: right;">R {{ advance_amount|floatformat:2 }}</td>
</tr>
{% elif is_loan %}
<tr>
<td>Loan Payment: {{ loan_description }}</td>
<td style="text-align: right;">R {{ loan_amount|floatformat:2 }}</td>
</tr>
{% else %}
<!-- Base pay line -->
<tr>

View File

@ -545,6 +545,22 @@
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2" placeholder="Reason for this adjustment..."></textarea>
</div>
{# Pay Immediately — only shown for New Loan type #}
{# When checked, the loan is paid to the worker right away and #}
{# a payslip is emailed to Spark. When unchecked, the loan sits #}
{# in pending payments and gets included in the next pay cycle. #}
<div class="col-12" id="addAdjPayImmediatelyGroup" style="display: none;">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="pay_immediately"
id="addAdjPayImmediately" value="1" checked>
<label class="form-check-label" for="addAdjPayImmediately">
<i class="fas fa-bolt me-1 text-warning"></i>
<strong>Pay Immediately</strong>
<span class="text-muted small">— send payslip to Spark now and record as paid</span>
</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
@ -1295,9 +1311,10 @@ document.addEventListener('DOMContentLoaded', function() {
const adjSelectedCount = document.getElementById('adjSelectedCount');
const addAdjWorkerError = document.getElementById('addAdjWorkerError');
// Show/hide project field based on adjustment type.
// Show/hide project field and "Pay Immediately" checkbox based on adjustment type.
// Also toggles the HTML "required" attribute so browser validation
// only enforces the project when the type actually needs one.
var addAdjPayImmediatelyGroup = document.getElementById('addAdjPayImmediatelyGroup');
function toggleProjectField() {
if (!addAdjType || !addAdjProjectGroup) return;
// Loan types and repayments don't need a project.
@ -1313,6 +1330,10 @@ document.addEventListener('DOMContentLoaded', function() {
addAdjProject.value = ''; // Clear selection when hidden
}
}
// "Pay Immediately" checkbox — only shown for New Loan type
if (addAdjPayImmediatelyGroup) {
addAdjPayImmediatelyGroup.style.display = (addAdjType.value === 'New Loan') ? '' : 'none';
}
}
if (addAdjType) {
addAdjType.addEventListener('change', toggleProjectField);

View File

@ -28,10 +28,10 @@
<div class="col-md-6">
<h6 class="text-uppercase text-muted fw-bold small mb-1">Payment To Beneficiary:</h6>
<h2 class="fw-bold text-dark mb-0 text-uppercase">{{ record.worker.name }}</h2>
<p class="text-muted small mb-0">{% if is_advance %}Advance{% endif %} Payslip No. #{{ record.id|stringformat:"06d" }}</p>
<p class="text-muted small mb-0">{% if is_advance %}Advance{% elif is_loan %}Loan{% endif %} Payslip No. #{{ record.id|stringformat:"06d" }}</p>
</div>
<div class="col-md-6 text-md-end mt-3 mt-md-0">
<h3 class="fw-bold text-uppercase text-secondary mb-1">{% if is_advance %}Advance{% endif %} Payslip</h3>
<h3 class="fw-bold text-uppercase text-secondary mb-1">{% if is_advance %}Advance{% elif is_loan %}Loan{% endif %} Payslip</h3>
<div class="fw-bold">{{ record.date|date:"F j, Y" }}</div>
<div class="text-muted small">Payer: Fox Fitt</div>
</div>
@ -90,6 +90,42 @@
</div>
</div>
{% elif is_loan %}
<!-- === LOAN PAYMENT DETAIL === -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Loan Details</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
<th>Description</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ loan_adj.date|date:"M d, Y" }}</td>
<td><span class="badge bg-warning text-dark text-uppercase">Loan Payment</span></td>
<td>{{ loan_adj.description|default:"Worker loan" }}</td>
<td class="text-end text-success fw-bold">R {{ loan_adj.amount|floatformat:2 }}</td>
</tr>
</tbody>
</table>
</div>
<!-- === LOAN TOTAL === -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Loan Amount:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ loan_adj.amount|floatformat:2 }}</td>
</tr>
</table>
</div>
</div>
{% else %}
<!-- === WORK LOG TABLE — each day worked === -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>

View File

@ -107,7 +107,7 @@
<div class="header">
<div class="sub-header">PAYMENT TO BENEFICIARY</div>
<div class="beneficiary-name">{{ record.worker.name }}</div>
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
<div class="title">{% if is_advance %}Advance Payslip #{{ record.id }}{% elif is_loan %}Loan Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}</div>
</div>
<!-- Beneficiary details box -->
@ -132,6 +132,12 @@
<td>Advance Payment: {{ advance_description }}</td>
<td class="text-right">R {{ advance_amount|floatformat:2 }}</td>
</tr>
{% elif is_loan %}
<!-- Loan Payment — single line item -->
<tr>
<td>Loan Payment: {{ loan_description }}</td>
<td class="text-right">R {{ loan_amount|floatformat:2 }}</td>
</tr>
{% else %}
<!-- Base Pay — number of days worked × day rate -->
<tr>

View File

@ -1338,18 +1338,27 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount,
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
# as a positive amount instead of the confusing "0 days + deduction" format).
# === DETECT STANDALONE PAYMENT (no work logs, single adjustment) ===
# Advance-only or Loan-only payments use a cleaner payslip layout
# showing just the amount instead of "0 days worked + adjustment".
advance_adj = None
loan_adj = None
if log_count == 0:
adjs_list = list(payroll_record.adjustments.all())
if len(adjs_list) == 1 and adjs_list[0].type == 'Advance Payment':
advance_adj = adjs_list[0]
if len(adjs_list) == 1:
if adjs_list[0].type == 'Advance Payment':
advance_adj = adjs_list[0]
elif adjs_list[0].type == 'New Loan':
loan_adj = adjs_list[0]
is_advance = advance_adj is not None
subject = f"{'Advance ' if is_advance else ''}Payslip for {worker.name} - {payroll_record.date}"
is_loan = loan_adj is not None
if is_advance:
subject = f"Advance Payslip for {worker.name} - {payroll_record.date}"
elif is_loan:
subject = f"Loan Payslip for {worker.name} - {payroll_record.date}"
else:
subject = f"Payslip for {worker.name} - {payroll_record.date}"
# Context for both the HTML email body and the PDF attachment
email_context = {
@ -1361,6 +1370,9 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount,
'is_advance': is_advance,
'advance_amount': advance_adj.amount if advance_adj else None,
'advance_description': advance_adj.description if advance_adj else '',
'is_loan': is_loan,
'loan_amount': loan_adj.amount if loan_adj else None,
'loan_description': loan_adj.description if loan_adj else '',
}
# 1. Render HTML email body
@ -1760,6 +1772,10 @@ def add_adjustment(request):
continue
# === NEW LOAN — create a Loan record (loan_type='loan') ===
# If "Pay Immediately" is checked (default), the loan is processed
# right away — PayrollRecord is created, payslip emailed to Spark,
# and the adjustment is marked as paid. If unchecked, the loan sits
# in Pending Payments and is included in the next pay cycle.
if adj_type == 'New Loan':
loan = Loan.objects.create(
worker=worker,
@ -1770,6 +1786,30 @@ def add_adjustment(request):
reason=description,
)
pay_immediately = request.POST.get('pay_immediately') == '1'
if pay_immediately:
# Create the adjustment and immediately mark it as paid
loan_adj = PayrollAdjustment.objects.create(
worker=worker,
type='New Loan',
amount=amount,
date=adj_date,
description=description,
loan=loan,
)
payroll_record = PayrollRecord.objects.create(
worker=worker,
amount_paid=amount,
date=adj_date,
)
loan_adj.payroll_record = payroll_record
loan_adj.save()
# Send payslip email to Spark
_send_payslip_email(request, worker, payroll_record, 0, Decimal('0.00'))
created_count += 1
continue # Skip the generic PayrollAdjustment creation below
# === 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.
@ -2196,13 +2236,16 @@ def payslip_detail(request, pk):
# Calculate net adjustment amount (additive minus deductive)
adjustments_net = record.amount_paid - base_pay
# === DETECT ADVANCE-ONLY PAYMENT ===
# If payment has 0 work logs and a single Advance Payment adjustment,
# show a cleaner "advance payslip" layout instead of "0 days worked".
# === DETECT STANDALONE PAYMENT (no work logs, single adjustment) ===
# Advance-only or Loan-only payments use a cleaner layout.
adjs_list = list(adjustments)
advance_adj = None
if logs.count() == 0 and len(adjs_list) == 1 and adjs_list[0].type == 'Advance Payment':
advance_adj = adjs_list[0]
loan_adj = None
if logs.count() == 0 and len(adjs_list) == 1:
if adjs_list[0].type == 'Advance Payment':
advance_adj = adjs_list[0]
elif adjs_list[0].type == 'New Loan':
loan_adj = adjs_list[0]
context = {
'record': record,
@ -2214,6 +2257,8 @@ def payslip_detail(request, pk):
'deductive_types': DEDUCTIVE_TYPES,
'is_advance': advance_adj is not None,
'advance_adj': advance_adj,
'is_loan': loan_adj is not None,
'loan_adj': loan_adj,
}
return render(request, 'core/payslip.html', context)