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()
]
# 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
if EMAIL_USE_SSL:
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">Amount Paid</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>
</thead>
<tbody>
@ -264,7 +265,7 @@
<td class="align-middle">
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
</td>
<td class="pe-4 align-middle">
<td class="align-middle">
{% 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">
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
@ -273,10 +274,15 @@
<span class="text-muted">-</span>
{% endfor %}
</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>
{% empty %}
<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>
No payment history yet.
</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)
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 ===
# Visit /import-data/ once to populate the database. Remove after use.
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.auth.decorators import login_required
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 .forms import AttendanceLogForm, PayrollAdjustmentForm
from .utils import render_to_pdf
# === PAYROLL CONSTANTS ===
@ -779,11 +784,68 @@ def process_payment(request, worker_id):
adj.loan.active = False
adj.loan.save()
messages.success(
request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
f'{log_count} work log(s) marked as paid.'
)
# =========================================================================
# 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(
request,
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
f'{log_count} work log(s) marked as paid.'
)
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) ===
# 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