Add payslip feature: detail page, PDF generation, and email to Spark

- core/utils.py: render_to_pdf() wrapper for xhtml2pdf
- core/templates/core/pdf/payslip_pdf.html: A4 PDF payslip (matches V2 layout)
- core/templates/core/email/payslip_email.html: HTML email body for Spark
- core/templates/core/payslip.html: browser payslip detail page with print
- core/views.py: add payslip_detail view, wire email+PDF into process_payment
- core/urls.py: add payroll/payslip/<pk>/ route
- config/settings.py: add SPARK_RECEIPT_EMAIL setting
- payroll_dashboard.html: add "View" payslip link in Payment History tab

All templates show adjustments (bonuses, deductions, overtime, loan repayments)
as line items. Amounts always show 2 decimal places. Email failure does not
roll back payment — handled gracefully with warning message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-22 20:37:04 +02:00
parent 1681ed26a2
commit c8c78dd88e
9 changed files with 598 additions and 8 deletions

View File

@ -175,6 +175,10 @@ CONTACT_EMAIL_TO = [
if item.strip() if item.strip()
] ]
# Spark Receipt Email — payslip and receipt PDFs are sent here for accounting
# Set SPARK_RECEIPT_EMAIL in your .env file (e.g. receipts@spark.co.za)
SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "")
# When both TLS and SSL flags are enabled, prefer SSL explicitly # When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL: if EMAIL_USE_SSL:
EMAIL_USE_TLS = False EMAIL_USE_TLS = False

View File

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html>
<head>
<style>
/* === EMAIL STYLES ===
Email clients have limited CSS support, so we use inline-friendly styles.
Worker name is the dominant element — no prominent Fox Fitt branding
(Spark reads the vendor name from the document). */
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 20px; }
.header { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.beneficiary-name { font-size: 24px; font-weight: bold; text-transform: uppercase; color: #000; }
.sub-header { font-size: 14px; color: #666; margin-bottom: 5px; }
.title { font-size: 18px; font-weight: bold; color: #666; }
.meta { margin-bottom: 20px; background-color: #f8f9fa; padding: 10px; border-radius: 4px; }
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.items-table th, .items-table td { border-bottom: 1px solid #eee; padding: 8px; text-align: left; }
.items-table th { background-color: #f8f9fa; }
.totals { text-align: right; margin-top: 20px; border-top: 2px solid #333; padding-top: 10px; }
.total-row { font-size: 20px; font-weight: bold; color: #000; }
.footer { margin-top: 30px; font-size: 12px; color: #777; text-align: center; border-top: 1px solid #eee; padding-top: 10px; }
.positive { color: green; }
.negative { color: red; }
</style>
</head>
<body>
<div class="container">
<!-- Header: worker name dominant -->
<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>
<!-- Beneficiary details -->
<div class="meta">
<strong>Beneficiary:</strong> {{ record.worker.name }}<br>
<strong>ID Number:</strong> {{ record.worker.id_number }}<br>
<strong>Date:</strong> {{ record.date }}
</div>
<!-- Line items table -->
<table class="items-table">
<thead>
<tr>
<th>Description</th>
<th style="text-align: right;">Amount</th>
</tr>
</thead>
<tbody>
{% if is_advance %}
<tr>
<td>Advance Payment: {{ advance_description }}</td>
<td style="text-align: right;">R {{ advance_amount|floatformat:2 }}</td>
</tr>
{% else %}
<!-- Base pay line -->
<tr>
<td>Base Pay ({{ logs_count }} days worked)</td>
<td style="text-align: right;">R {{ logs_amount|floatformat:2 }}</td>
</tr>
<!-- All adjustments (bonuses add, deductions subtract) -->
{% for adj in adjustments %}
<tr>
<td>{{ adj.get_type_display }}: {{ adj.description }}</td>
<td style="text-align: right;" class="{% if adj.type in deductive_types %}negative{% else %}positive{% endif %}">
{% if adj.type in deductive_types %}- {% endif %}R {{ adj.amount|floatformat:2 }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
<!-- Net pay total -->
<div class="totals">
<p class="total-row">Net Pay: R {{ record.amount_paid|floatformat:2 }}</p>
</div>
<!-- Footer — minimal branding -->
<div class="footer">
<p>Payer: Fox Fitt | Generated for {{ record.worker.name }}</p>
<p>Date Generated: {% now "Y-m-d H:i" %}</p>
</div>
</div>
</body>
</html>

View File

@ -252,7 +252,8 @@
<th scope="col">Worker</th> <th scope="col">Worker</th>
<th scope="col">Amount Paid</th> <th scope="col">Amount Paid</th>
<th scope="col">Work Logs</th> <th scope="col">Work Logs</th>
<th scope="col" class="pe-4">Adjustments</th> <th scope="col">Adjustments</th>
<th scope="col" class="pe-4 text-end">Payslip</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -264,7 +265,7 @@
<td class="align-middle"> <td class="align-middle">
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }} {{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
</td> </td>
<td class="pe-4 align-middle"> <td class="align-middle">
{% for adj in record.adjustments.all %} {% for adj in record.adjustments.all %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' %}bg-success{% else %}bg-danger{% endif %} me-1"> <span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' %}bg-success{% else %}bg-danger{% endif %} me-1">
{{ adj.type }}: R {{ adj.amount|floatformat:2 }} {{ adj.type }}: R {{ adj.amount|floatformat:2 }}
@ -273,10 +274,15 @@
<span class="text-muted">-</span> <span class="text-muted">-</span>
{% endfor %} {% endfor %}
</td> </td>
<td class="pe-4 align-middle text-end">
<a href="{% url 'payslip_detail' record.id %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-file-alt me-1"></i> View
</a>
</td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="text-center py-5 text-muted"> <td colspan="6" class="text-center py-5 text-muted">
<i class="fas fa-inbox fa-2x mb-3 d-block opacity-50"></i> <i class="fas fa-inbox fa-2x mb-3 d-block opacity-50"></i>
No payment history yet. No payment history yet.
</td> </td>

View File

@ -0,0 +1,165 @@
{% extends 'base.html' %}
{% block title %}Payslip #{{ record.id }} | Fox Fitt{% endblock %}
{% block content %}
<!-- === PAYSLIP DETAIL PAGE ===
Shows a completed payment with work logs, adjustments, and totals.
Reached from the Payment History tab on the payroll dashboard.
Has a Print button that uses the browser's native print dialog. -->
<div class="container py-5">
<!-- Action buttons (hidden when printing) -->
<div class="d-print-none mb-4 d-grid gap-2 d-md-flex">
<a href="{% url 'payroll_dashboard' %}?status=paid" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Back to Payment History
</a>
<button onclick="window.print()" class="btn btn-accent">
<i class="fas fa-print me-1"></i> Print Payslip
</button>
</div>
<!-- Payslip card -->
<div class="card border-0 shadow-sm" id="payslip-card">
<div class="card-body p-5">
<!-- === HEADER — worker name is the dominant element === -->
<div class="row mb-5 border-bottom pb-4 align-items-center">
<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">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">Payslip</h3>
<div class="fw-bold">{{ record.date|date:"F j, Y" }}</div>
<div class="text-muted small">Payer: Fox Fitt</div>
</div>
</div>
<!-- === WORKER DETAILS + NET PAY === -->
<div class="row mb-5">
<div class="col-md-6">
<h6 class="text-uppercase text-muted fw-bold small mb-3">Beneficiary Details:</h6>
<h4 class="fw-bold">{{ record.worker.name }}</h4>
<p class="mb-0">ID Number: <strong>{{ record.worker.id_number }}</strong></p>
<p class="mb-0">Phone: {{ record.worker.phone_number|default:"—" }}</p>
</div>
<div class="col-md-6 text-md-end mt-4 mt-md-0">
<h6 class="text-uppercase text-muted fw-bold small mb-3">Net Payable Amount:</h6>
<div class="display-6 fw-bold text-dark">R {{ record.amount_paid|floatformat:2 }}</div>
<p class="text-success small fw-bold mt-2">
<i class="fas fa-check-circle me-1"></i> PAID
</p>
</div>
</div>
<!-- === WORK LOG TABLE — each day worked === -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
<div class="table-responsive mb-4">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Project</th>
<th>Notes</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.date|date:"M d, Y" }}</td>
<td>{{ log.project.name }}</td>
<td>{{ log.notes|default:"—"|truncatechars:50 }}</td>
<td class="text-end">R {{ record.worker.daily_rate|floatformat:2 }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center text-muted">
<i class="fas fa-info-circle me-1"></i> No work logs in this period.
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light">
<tr>
<td colspan="3" class="text-end fw-bold">Base Pay Subtotal</td>
<td class="text-end fw-bold">R {{ base_pay|floatformat:2 }}</td>
</tr>
</tfoot>
</table>
</div>
<!-- === ADJUSTMENTS TABLE — bonuses, deductions, overtime, loan repayments === -->
{% if adjustments %}
<h6 class="text-uppercase text-muted fw-bold small mb-3 mt-4">Adjustments (Bonuses, Deductions, Loans)</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>
{% for adj in adjustments %}
<tr>
<td>{{ adj.date|date:"M d, Y" }}</td>
<td>
<span class="badge bg-secondary text-uppercase">{{ adj.get_type_display }}</span>
</td>
<td>{{ adj.description }}</td>
<td class="text-end {% if adj.type in deductive_types %}text-danger{% else %}text-success{% endif %}">
{% if adj.type in deductive_types %}
- R {{ adj.amount|floatformat:2 }}
{% else %}
+ R {{ adj.amount|floatformat:2 }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- === GRAND TOTAL SUMMARY === -->
<div class="row justify-content-end mt-4">
<div class="col-md-5">
<table class="table table-sm border-0">
<tr>
<td class="text-end border-0 text-muted">Base Pay:</td>
<td class="text-end border-0" width="140">R {{ base_pay|floatformat:2 }}</td>
</tr>
{% if adjustments %}
<tr>
<td class="text-end border-0 text-muted">Adjustments Net:</td>
<td class="text-end border-0">
{% if adjustments_net >= 0 %}
+ R {{ adjustments_net|floatformat:2 }}
{% else %}
- R {{ adjustments_net_abs|floatformat:2 }}
{% endif %}
</td>
</tr>
{% endif %}
<tr class="border-top border-dark">
<td class="text-end border-0 fw-bold fs-5">Net Payable:</td>
<td class="text-end border-0 fw-bold fs-5">R {{ record.amount_paid|floatformat:2 }}</td>
</tr>
</table>
</div>
</div>
<!-- === FOOTER === -->
<div class="text-center text-muted small mt-5 pt-4 border-top">
<p>This is a computer-generated document and does not require a signature.</p>
<p>Payer: Fox Fitt &copy; 2026</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,165 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* === PAGE SETUP === */
/* A4 portrait with 2cm margins and a footer frame at the bottom */
@page {
size: a4 portrait;
margin: 2cm;
@frame footer_frame {
-pdf-frame-content: footerContent;
bottom: 1cm;
margin-left: 1cm;
margin-right: 1cm;
height: 1cm;
}
}
/* === BODY STYLES === */
body {
font-family: Helvetica, sans-serif;
font-size: 12pt;
line-height: 1.5;
color: #333;
}
/* === HEADER — worker name is the dominant element === */
.header {
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 10px;
margin-bottom: 20px;
}
.beneficiary-name {
font-size: 24pt;
font-weight: bold;
text-transform: uppercase;
color: #000;
}
.sub-header {
font-size: 12pt;
color: #666;
margin-bottom: 5px;
}
.title {
font-size: 18pt;
font-weight: bold;
color: #666;
}
/* === META BOX — beneficiary details === */
.meta {
margin-bottom: 20px;
padding: 10px;
border: 1px solid #ddd;
background-color: #f9f9f9;
}
/* === ITEMS TABLE — base pay + adjustments === */
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.items-table th {
border-bottom: 1px solid #000;
padding: 8px;
text-align: left;
background-color: #eee;
font-weight: bold;
}
.items-table td {
border-bottom: 1px solid #eee;
padding: 8px;
text-align: left;
}
/* === TOTALS === */
.totals {
text-align: right;
margin-top: 20px;
border-top: 2px solid #333;
padding-top: 10px;
}
.total-row {
font-size: 16pt;
font-weight: bold;
color: #000;
}
/* === FOOTER — small print at bottom of page === */
.footer {
text-align: center;
font-size: 10pt;
color: #777;
}
/* Helpers */
.text-right { text-align: right; }
.positive { color: green; }
.negative { color: red; }
</style>
</head>
<body>
<!-- Header: worker name is the biggest element (per CLAUDE.md rule) -->
<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>
<!-- Beneficiary details box -->
<div class="meta">
<strong>Beneficiary:</strong> {{ record.worker.name }}<br>
<strong>ID Number:</strong> {{ record.worker.id_number }}<br>
<strong>Date:</strong> {{ record.date }}
</div>
<!-- Line items: base pay + all adjustments -->
<table class="items-table">
<thead>
<tr>
<th>Description</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
{% if is_advance %}
<!-- Advance Payment — single line item -->
<tr>
<td>Advance Payment: {{ advance_description }}</td>
<td class="text-right">R {{ advance_amount|floatformat:2 }}</td>
</tr>
{% else %}
<!-- Base Pay — number of days worked × day rate -->
<tr>
<td>Base Pay ({{ logs_count }} days worked)</td>
<td class="text-right">R {{ logs_amount|floatformat:2 }}</td>
</tr>
<!-- All payroll adjustments (bonuses, deductions, overtime, loan repayments) -->
{% for adj in adjustments %}
<tr>
<td>{{ adj.get_type_display }}: {{ adj.description }}</td>
<td class="text-right {% if adj.type in deductive_types %}negative{% else %}positive{% endif %}">
{% if adj.type in deductive_types %}- {% endif %}R {{ adj.amount|floatformat:2 }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
<!-- Net pay total -->
<div class="totals">
<p class="total-row">Net Pay: R {{ record.amount_paid|floatformat:2 }}</p>
</div>
<!-- Footer frame (positioned at bottom of page by xhtml2pdf) -->
<div id="footerContent" class="footer">
Payer: Fox Fitt | Generated for {{ record.worker.name }} | {% now "Y-m-d H:i" %}
</div>
</body>
</html>

View File

@ -43,6 +43,9 @@ urlpatterns = [
# Preview a worker's payslip (AJAX — returns JSON) # Preview a worker's payslip (AJAX — returns JSON)
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'), path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
# View a completed payslip (print-friendly page)
path('payroll/payslip/<int:pk>/', views.payslip_detail, name='payslip_detail'),
# === TEMPORARY: Import production data from browser === # === TEMPORARY: Import production data from browser ===
# Visit /import-data/ once to populate the database. Remove after use. # Visit /import-data/ once to populate the database. Remove after use.
path('import-data/', views.import_data, name='import_data'), path('import-data/', views.import_data, name='import_data'),

34
core/utils.py Normal file
View File

@ -0,0 +1,34 @@
# === PDF GENERATION ===
# Converts a Django HTML template into a PDF file using xhtml2pdf.
# Used for payslip and receipt PDF attachments sent via email.
from io import BytesIO
from django.template.loader import get_template
from xhtml2pdf import pisa
def render_to_pdf(template_src, context_dict=None):
"""
Render a Django template to PDF bytes.
Args:
template_src: Path to the template (e.g. 'core/pdf/payslip_pdf.html')
context_dict: Template context variables
Returns:
PDF content as bytes, or None if there was an error.
"""
if context_dict is None:
context_dict = {}
# Load and render the HTML template
template = get_template(template_src)
html = template.render(context_dict)
# Convert HTML to PDF
result = BytesIO()
pdf = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), result)
if not pdf.err:
return result.getvalue()
return None

View File

@ -15,9 +15,14 @@ from django.db.models.functions import TruncMonth
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
from .models import Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment from .models import Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment
from .forms import AttendanceLogForm, PayrollAdjustmentForm from .forms import AttendanceLogForm, PayrollAdjustmentForm
from .utils import render_to_pdf
# === PAYROLL CONSTANTS === # === PAYROLL CONSTANTS ===
@ -779,11 +784,68 @@ def process_payment(request, worker_id):
adj.loan.active = False adj.loan.active = False
adj.loan.save() 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.)
# =========================================================================
subject = f"Payslip for {worker.name} - {payroll_record.date}"
# Context for both the HTML email body and the PDF attachment
email_context = {
'record': payroll_record,
'logs_count': log_count,
'logs_amount': logs_amount,
'adjustments': payroll_record.adjustments.all(),
'deductive_types': DEDUCTIVE_TYPES,
}
# 1. Render HTML email body
html_message = render_to_string('core/email/payslip_email.html', email_context)
plain_message = strip_tags(html_message)
# 2. Render PDF attachment
pdf_content = render_to_pdf('core/pdf/payslip_pdf.html', email_context)
# 3. Send email with PDF attached
recipient = getattr(settings, 'SPARK_RECEIPT_EMAIL', None)
if recipient:
try:
email = EmailMultiAlternatives(
subject,
plain_message,
settings.DEFAULT_FROM_EMAIL,
[recipient],
)
email.attach_alternative(html_message, "text/html")
if pdf_content:
email.attach(
f"Payslip_{worker.id}_{payroll_record.date}.pdf",
pdf_content,
'application/pdf'
)
email.send()
messages.success(
request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
f'Payslip emailed successfully.'
)
except Exception as e:
# Payment is saved — just warn that email failed
messages.warning(
request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}, '
f'but email delivery failed: {str(e)}'
)
else:
# No SPARK_RECEIPT_EMAIL configured — just show success
messages.success( messages.success(
request, request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}. ' f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
f'{log_count} work log(s) marked as paid.' f'{log_count} work log(s) marked as paid.'
) )
return redirect('payroll_dashboard') return redirect('payroll_dashboard')
@ -1101,6 +1163,46 @@ def preview_payslip(request, worker_id):
}) })
# =============================================================================
# === PAYSLIP DETAIL ===
# Shows a completed payment (PayrollRecord) as a printable payslip page.
# Displays: worker details, work log table, adjustments table, totals.
# Reached from the "Payment History" tab on the payroll dashboard.
# =============================================================================
@login_required
def payslip_detail(request, pk):
"""Show a completed payslip with work logs, adjustments, and totals."""
if not is_admin(request.user):
return redirect('payroll_dashboard')
record = get_object_or_404(PayrollRecord, pk=pk)
# Get the work logs included in this payment
logs = record.work_logs.select_related('project').order_by('date')
# Get the adjustments linked to this payment
adjustments = record.adjustments.all().order_by('type')
# Calculate base pay from logs
# Each log = 1 day of work at the worker's daily rate
base_pay = record.worker.daily_rate * logs.count()
# Calculate net adjustment amount (additive minus deductive)
adjustments_net = record.amount_paid - base_pay
context = {
'record': record,
'logs': logs,
'adjustments': adjustments,
'base_pay': base_pay,
'adjustments_net': adjustments_net,
'adjustments_net_abs': abs(adjustments_net),
'deductive_types': DEDUCTIVE_TYPES,
}
return render(request, 'core/payslip.html', context)
# ============================================================================= # =============================================================================
# === IMPORT DATA (TEMPORARY) === # === IMPORT DATA (TEMPORARY) ===
# Runs the import_production_data command from the browser. # Runs the import_production_data command from the browser.

View File

@ -0,0 +1,23 @@
# Payslip Feature Design — 22 Feb 2026
## Goal
Complete the payment workflow: when "Pay" is clicked, generate a PDF payslip and email it to Spark. Also add a payslip detail page for viewing/printing past payslips.
## Files
1. `core/utils.py` — render_to_pdf() xhtml2pdf wrapper
2. `core/templates/core/pdf/payslip_pdf.html` — A4 PDF template
3. `core/templates/core/email/payslip_email.html` — HTML email body
4. `core/templates/core/payslip.html` — Browser payslip detail page
5. `core/views.py` — payslip_detail view + email in process_payment
6. `core/urls.py` — payroll/payslip/<pk>/
7. `config/settings.py` — SPARK_RECEIPT_EMAIL + DEFAULT_FROM_EMAIL
## Flow
process_payment → atomic(create record, link logs/adjs, update loans) → email PDF to Spark → redirect
## Key: V2 → V5 field mapping
- record.amount → record.amount_paid
- worker.id_no → worker.id_number
- worker.phone_no → worker.phone_number
- loan.balance → loan.remaining_balance
- loan.amount → loan.principal_amount