From c8c78dd88eff712c9e8c47a3666c29f41b1ef245 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Sun, 22 Feb 2026 20:37:04 +0200 Subject: [PATCH] Add payslip feature: detail page, PDF generation, and email to Spark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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// 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 --- config/settings.py | 4 + core/templates/core/email/payslip_email.html | 88 ++++++++++ core/templates/core/payroll_dashboard.html | 12 +- core/templates/core/payslip.html | 165 ++++++++++++++++++ core/templates/core/pdf/payslip_pdf.html | 165 ++++++++++++++++++ core/urls.py | 3 + core/utils.py | 34 ++++ core/views.py | 112 +++++++++++- .../2026-02-22-payslip-feature-design.md | 23 +++ 9 files changed, 598 insertions(+), 8 deletions(-) create mode 100644 core/templates/core/email/payslip_email.html create mode 100644 core/templates/core/payslip.html create mode 100644 core/templates/core/pdf/payslip_pdf.html create mode 100644 core/utils.py create mode 100644 docs/plans/2026-02-22-payslip-feature-design.md diff --git a/config/settings.py b/config/settings.py index 26684a0..318a684 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 diff --git a/core/templates/core/email/payslip_email.html b/core/templates/core/email/payslip_email.html new file mode 100644 index 0000000..ad7a262 --- /dev/null +++ b/core/templates/core/email/payslip_email.html @@ -0,0 +1,88 @@ + + + + + + +
+ +
+
PAYMENT TO BENEFICIARY
+
{{ record.worker.name }}
+
{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}
+
+ + +
+ Beneficiary: {{ record.worker.name }}
+ ID Number: {{ record.worker.id_number }}
+ Date: {{ record.date }} +
+ + + + + + + + + + + {% if is_advance %} + + + + + {% else %} + + + + + + + + {% for adj in adjustments %} + + + + + {% endfor %} + {% endif %} + +
DescriptionAmount
Advance Payment: {{ advance_description }}R {{ advance_amount|floatformat:2 }}
Base Pay ({{ logs_count }} days worked)R {{ logs_amount|floatformat:2 }}
{{ adj.get_type_display }}: {{ adj.description }} + {% if adj.type in deductive_types %}- {% endif %}R {{ adj.amount|floatformat:2 }} +
+ + +
+

Net Pay: R {{ record.amount_paid|floatformat:2 }}

+
+ + + +
+ + diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index 775cd81..038b418 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -252,7 +252,8 @@ Worker Amount Paid Work Logs - Adjustments + Adjustments + Payslip @@ -264,7 +265,7 @@ {{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }} - + {% for adj in record.adjustments.all %} {{ adj.type }}: R {{ adj.amount|floatformat:2 }} @@ -273,10 +274,15 @@ - {% endfor %} + + + View + + {% empty %} - + No payment history yet. diff --git a/core/templates/core/payslip.html b/core/templates/core/payslip.html new file mode 100644 index 0000000..0442561 --- /dev/null +++ b/core/templates/core/payslip.html @@ -0,0 +1,165 @@ +{% extends 'base.html' %} + +{% block title %}Payslip #{{ record.id }} | Fox Fitt{% endblock %} + +{% block content %} + + +
+ +
+ + Back to Payment History + + +
+ + +
+
+ + +
+
+
Payment To Beneficiary:
+

{{ record.worker.name }}

+

Payslip No. #{{ record.id|stringformat:"06d" }}

+
+
+

Payslip

+
{{ record.date|date:"F j, Y" }}
+
Payer: Fox Fitt
+
+
+ + +
+
+
Beneficiary Details:
+

{{ record.worker.name }}

+

ID Number: {{ record.worker.id_number }}

+

Phone: {{ record.worker.phone_number|default:"—" }}

+
+
+
Net Payable Amount:
+
R {{ record.amount_paid|floatformat:2 }}
+

+ PAID +

+
+
+ + +
Work Log Details (Attendance)
+
+ + + + + + + + + + + {% for log in logs %} + + + + + + + {% empty %} + + + + {% endfor %} + + + + + + + +
DateProjectNotesAmount
{{ log.date|date:"M d, Y" }}{{ log.project.name }}{{ log.notes|default:"—"|truncatechars:50 }}R {{ record.worker.daily_rate|floatformat:2 }}
+ No work logs in this period. +
Base Pay SubtotalR {{ base_pay|floatformat:2 }}
+
+ + + {% if adjustments %} +
Adjustments (Bonuses, Deductions, Loans)
+
+ + + + + + + + + + + {% for adj in adjustments %} + + + + + + + {% endfor %} + +
DateTypeDescriptionAmount
{{ adj.date|date:"M d, Y" }} + {{ adj.get_type_display }} + {{ adj.description }} + {% if adj.type in deductive_types %} + - R {{ adj.amount|floatformat:2 }} + {% else %} + + R {{ adj.amount|floatformat:2 }} + {% endif %} +
+
+ {% endif %} + + +
+
+ + + + + + {% if adjustments %} + + + + + {% endif %} + + + + +
Base Pay:R {{ base_pay|floatformat:2 }}
Adjustments Net: + {% if adjustments_net >= 0 %} + + R {{ adjustments_net|floatformat:2 }} + {% else %} + - R {{ adjustments_net_abs|floatformat:2 }} + {% endif %} +
Net Payable:R {{ record.amount_paid|floatformat:2 }}
+
+
+ + +
+

This is a computer-generated document and does not require a signature.

+

Payer: Fox Fitt © 2026

+
+
+
+
+{% endblock %} diff --git a/core/templates/core/pdf/payslip_pdf.html b/core/templates/core/pdf/payslip_pdf.html new file mode 100644 index 0000000..6d2a5b0 --- /dev/null +++ b/core/templates/core/pdf/payslip_pdf.html @@ -0,0 +1,165 @@ + + + + + + + + +
+
PAYMENT TO BENEFICIARY
+
{{ record.worker.name }}
+
{% if is_advance %}Advance Payslip #{{ record.id }}{% else %}Payslip #{{ record.id }}{% endif %}
+
+ + +
+ Beneficiary: {{ record.worker.name }}
+ ID Number: {{ record.worker.id_number }}
+ Date: {{ record.date }} +
+ + + + + + + + + + + {% if is_advance %} + + + + + + {% else %} + + + + + + + + {% for adj in adjustments %} + + + + + {% endfor %} + {% endif %} + +
DescriptionAmount
Advance Payment: {{ advance_description }}R {{ advance_amount|floatformat:2 }}
Base Pay ({{ logs_count }} days worked)R {{ logs_amount|floatformat:2 }}
{{ adj.get_type_display }}: {{ adj.description }} + {% if adj.type in deductive_types %}- {% endif %}R {{ adj.amount|floatformat:2 }} +
+ + +
+

Net Pay: R {{ record.amount_paid|floatformat:2 }}

+
+ + + + + diff --git a/core/urls.py b/core/urls.py index 0183234..28731d5 100644 --- a/core/urls.py +++ b/core/urls.py @@ -43,6 +43,9 @@ urlpatterns = [ # Preview a worker's payslip (AJAX — returns JSON) path('payroll/preview//', views.preview_payslip, name='preview_payslip'), + # View a completed payslip (print-friendly page) + path('payroll/payslip//', 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'), diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..e6bdb5f --- /dev/null +++ b/core/utils.py @@ -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 diff --git a/core/views.py b/core/views.py index a5c4b83..3f11e67 100644 --- a/core/views.py +++ b/core/views.py @@ -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. diff --git a/docs/plans/2026-02-22-payslip-feature-design.md b/docs/plans/2026-02-22-payslip-feature-design.md new file mode 100644 index 0000000..1f4a246 --- /dev/null +++ b/docs/plans/2026-02-22-payslip-feature-design.md @@ -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// +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