diff --git a/core/forms.py b/core/forms.py
index 98fe64a..9c6853c 100644
--- a/core/forms.py
+++ b/core/forms.py
@@ -2,9 +2,11 @@
# Django form classes for the app.
# - AttendanceLogForm: daily work log creation with date ranges and conflict detection
# - PayrollAdjustmentForm: adding bonuses, deductions, overtime, and loan adjustments
+# - ExpenseReceiptForm + ExpenseLineItemFormSet: expense receipt creation with dynamic line items
from django import forms
-from .models import WorkLog, Project, Team, Worker, PayrollAdjustment
+from django.forms import inlineformset_factory
+from .models import WorkLog, Project, Team, Worker, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
class AttendanceLogForm(forms.ModelForm):
@@ -148,3 +150,61 @@ class PayrollAdjustmentForm(forms.ModelForm):
self.add_error('project', 'A project must be selected for this adjustment type.')
return cleaned_data
+
+
+# =============================================================================
+# === EXPENSE RECEIPT FORM ===
+# Used on the /receipts/create/ page.
+# The form handles receipt header fields (vendor, date, payment method, VAT type).
+# Line items are handled separately by the ExpenseLineItemFormSet below.
+# =============================================================================
+
+class ExpenseReceiptForm(forms.ModelForm):
+ """
+ Form for the receipt header — vendor, date, payment method, VAT type.
+ Line items (products + amounts) are handled by ExpenseLineItemFormSet.
+ """
+
+ class Meta:
+ model = ExpenseReceipt
+ fields = ['date', 'vendor_name', 'description', 'payment_method', 'vat_type']
+ widgets = {
+ 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
+ 'vendor_name': forms.TextInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'Vendor Name'
+ }),
+ 'description': forms.Textarea(attrs={
+ 'class': 'form-control',
+ 'rows': 2,
+ 'placeholder': 'What was purchased and why...'
+ }),
+ 'payment_method': forms.Select(attrs={'class': 'form-select'}),
+ # Radio buttons for VAT type — shown as 3 options side by side
+ 'vat_type': forms.RadioSelect(attrs={'class': 'form-check-input'}),
+ }
+
+
+# === LINE ITEM FORMSET ===
+# A "formset" is a group of identical mini-forms — one per line item.
+# inlineformset_factory creates it automatically from the parent-child relationship.
+# - extra=1: start with 1 blank row
+# - can_delete=True: allows removing rows (checks a hidden DELETE checkbox)
+ExpenseLineItemFormSet = inlineformset_factory(
+ ExpenseReceipt, # Parent model
+ ExpenseLineItem, # Child model
+ fields=['product_name', 'amount'],
+ extra=1, # Show 1 blank row by default
+ can_delete=True, # Allow deleting rows
+ widgets={
+ 'product_name': forms.TextInput(attrs={
+ 'class': 'form-control',
+ 'placeholder': 'Item Name'
+ }),
+ 'amount': forms.NumberInput(attrs={
+ 'class': 'form-control item-amount',
+ 'step': '0.01',
+ 'placeholder': '0.00'
+ }),
+ }
+)
diff --git a/core/templates/base.html b/core/templates/base.html
index e5dc640..f1f6de1 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -63,7 +63,7 @@
{% endif %}
-
+
Receipts
diff --git a/core/templates/core/create_receipt.html b/core/templates/core/create_receipt.html
new file mode 100644
index 0000000..f071c0b
--- /dev/null
+++ b/core/templates/core/create_receipt.html
@@ -0,0 +1,326 @@
+{% extends 'base.html' %}
+
+{% block title %}Create Receipt | Fox Fitt{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/email/receipt_email.html b/core/templates/core/email/receipt_email.html
new file mode 100644
index 0000000..89eb505
--- /dev/null
+++ b/core/templates/core/email/receipt_email.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Date: {{ receipt.date }}
+ Payment Method: {{ receipt.get_payment_method_display }}
+ Description: {{ receipt.description|default:"-" }}
+
+
+
+
+
+
+ | Item |
+ Amount |
+
+
+
+ {% for item in items %}
+
+ | {{ item.product_name }} |
+ R {{ item.amount|floatformat:2 }} |
+
+ {% endfor %}
+
+
+
+
+
+
Subtotal: R {{ receipt.subtotal|floatformat:2 }}
+
VAT ({{ receipt.get_vat_type_display }}): R {{ receipt.vat_amount|floatformat:2 }}
+
Total: R {{ receipt.total_amount|floatformat:2 }}
+
+
+
+
+
+
+
diff --git a/core/templates/core/pdf/receipt_pdf.html b/core/templates/core/pdf/receipt_pdf.html
new file mode 100644
index 0000000..4b3ad7d
--- /dev/null
+++ b/core/templates/core/pdf/receipt_pdf.html
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Date: {{ receipt.date }}
+ Payment Method: {{ receipt.get_payment_method_display }}
+ Description: {{ receipt.description|default:"-" }}
+
+
+
+
+
+
+ | Item |
+ Amount |
+
+
+
+ {% for item in items %}
+
+ | {{ item.product_name }} |
+ R {{ item.amount|floatformat:2 }} |
+
+ {% endfor %}
+
+
+
+
+
+
Subtotal: R {{ receipt.subtotal|floatformat:2 }}
+
VAT ({{ receipt.get_vat_type_display }}): R {{ receipt.vat_amount|floatformat:2 }}
+
Total: R {{ receipt.total_amount|floatformat:2 }}
+
+
+
+
+
+
diff --git a/core/urls.py b/core/urls.py
index 28731d5..d472c0e 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -46,6 +46,10 @@ urlpatterns = [
# View a completed payslip (print-friendly page)
path('payroll/payslip//', views.payslip_detail, name='payslip_detail'),
+ # === EXPENSE RECEIPTS ===
+ # Create a new expense receipt — emails HTML + PDF to Spark Receipt
+ path('receipts/create/', views.create_receipt, name='create_receipt'),
+
# === 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/views.py b/core/views.py
index d9eb97d..fd6251a 100644
--- a/core/views.py
+++ b/core/views.py
@@ -21,10 +21,10 @@ 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 .forms import AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, ExpenseLineItemFormSet
# NOTE: render_to_pdf is NOT imported here at the top level.
-# It's imported lazily inside process_payment() to avoid crashing the
-# entire app if xhtml2pdf is not installed on the server.
+# It's imported lazily inside process_payment() and create_receipt()
+# to avoid crashing the entire app if xhtml2pdf is not installed on the server.
# === PAYROLL CONSTANTS ===
@@ -1233,6 +1233,132 @@ def payslip_detail(request, pk):
return render(request, 'core/payslip.html', context)
+# =============================================================================
+# === CREATE EXPENSE RECEIPT ===
+# Single-page form for recording business expenses.
+# Supports dynamic line items (products + amounts) and VAT calculation.
+# On save: emails an HTML + PDF receipt to Spark Receipt for accounting.
+# =============================================================================
+
+@login_required
+def create_receipt(request):
+ """Create a new expense receipt and email it to Spark Receipt."""
+ if not is_staff_or_supervisor(request.user):
+ return redirect('home')
+
+ if request.method == 'POST':
+ form = ExpenseReceiptForm(request.POST)
+ items = ExpenseLineItemFormSet(request.POST)
+
+ if form.is_valid() and items.is_valid():
+ # Save the receipt header (but don't commit yet — need to set user)
+ receipt = form.save(commit=False)
+ receipt.user = request.user
+ receipt.save()
+
+ # Save line items — link them to this receipt
+ items.instance = receipt
+ line_items = items.save()
+
+ # === BACKEND VAT CALCULATION ===
+ # The frontend shows live totals, but we recalculate on the server
+ # using Python Decimal for accuracy (no floating-point rounding errors).
+ sum_amount = sum(item.amount for item in line_items)
+ vat_type = receipt.vat_type
+
+ if vat_type == 'Included':
+ # "VAT Included" means the entered amounts already include 15% VAT.
+ # To find the pre-VAT subtotal: divide by 1.15
+ # Example: R100 entered → Subtotal R86.96, VAT R13.04, Total R100
+ receipt.total_amount = sum_amount
+ receipt.subtotal = (sum_amount / Decimal('1.15')).quantize(Decimal('0.01'))
+ receipt.vat_amount = receipt.total_amount - receipt.subtotal
+ elif vat_type == 'Excluded':
+ # "VAT Excluded" means the entered amounts are pre-VAT.
+ # Add 15% on top for the total.
+ # Example: R100 entered → Subtotal R100, VAT R15, Total R115
+ receipt.subtotal = sum_amount
+ receipt.vat_amount = (sum_amount * Decimal('0.15')).quantize(Decimal('0.01'))
+ receipt.total_amount = receipt.subtotal + receipt.vat_amount
+ else:
+ # "None" — no VAT applies
+ receipt.subtotal = sum_amount
+ receipt.vat_amount = Decimal('0.00')
+ receipt.total_amount = sum_amount
+
+ receipt.save()
+
+ # =================================================================
+ # EMAIL RECEIPT (same pattern as payslip email)
+ # If email fails, the receipt is still saved.
+ # =================================================================
+
+ # Lazy import — avoids crashing the app if xhtml2pdf isn't installed
+ from .utils import render_to_pdf
+
+ subject = f"Receipt from {receipt.vendor_name} - {receipt.date}"
+ email_context = {
+ 'receipt': receipt,
+ 'items': line_items,
+ }
+
+ # 1. Render HTML email body
+ html_message = render_to_string(
+ 'core/email/receipt_email.html', email_context
+ )
+ plain_message = strip_tags(html_message)
+
+ # 2. Render PDF attachment (returns None if xhtml2pdf is not installed)
+ pdf_content = render_to_pdf(
+ 'core/pdf/receipt_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"Receipt_{receipt.id}.pdf",
+ pdf_content,
+ 'application/pdf'
+ )
+
+ email.send()
+ messages.success(
+ request,
+ 'Receipt created and sent to SparkReceipt.'
+ )
+ except Exception as e:
+ messages.warning(
+ request,
+ f'Receipt saved, but email failed: {str(e)}'
+ )
+ else:
+ messages.success(request, 'Receipt saved successfully.')
+
+ # Redirect back to a blank form for the next receipt
+ return redirect('create_receipt')
+
+ else:
+ # GET request — show a blank form with today's date
+ form = ExpenseReceiptForm(initial={'date': timezone.now().date()})
+ items = ExpenseLineItemFormSet()
+
+ return render(request, 'core/create_receipt.html', {
+ 'form': form,
+ 'items': items,
+ })
+
+
# =============================================================================
# === IMPORT DATA (TEMPORARY) ===
# Runs the import_production_data command from the browser.
diff --git a/docs/plans/2026-02-22-expense-receipt-design.md b/docs/plans/2026-02-22-expense-receipt-design.md
new file mode 100644
index 0000000..1ee8645
--- /dev/null
+++ b/docs/plans/2026-02-22-expense-receipt-design.md
@@ -0,0 +1,28 @@
+# Expense Receipt Feature — Design Doc
+
+## Date: 2026-02-22
+
+## Summary
+Straight port of V2's expense receipt feature to V5. Single-page form at `/receipts/create/` where admins and supervisors record business expenses with dynamic line items and VAT calculation. Automatically emails HTML + PDF receipt to Spark Receipt.
+
+## Files
+| File | Action |
+|------|--------|
+| `core/forms.py` | Add ExpenseReceiptForm + ExpenseLineItemFormSet |
+| `core/views.py` | Add create_receipt() view |
+| `core/urls.py` | Add /receipts/create/ route |
+| `core/templates/core/create_receipt.html` | Create form page |
+| `core/templates/core/email/receipt_email.html` | Create email template |
+| `core/templates/core/pdf/receipt_pdf.html` | Create PDF template |
+| `core/templates/base.html` | Add Receipts navbar link |
+
+## V5 Naming Adaptations
+- `vendor` -> `vendor_name`, `product` -> `product_name`
+- `items` related_name -> `line_items`
+- Choice values: Title Case ('Included') not UPPERCASE ('INCLUDED')
+- Lazy xhtml2pdf import (same as payslip)
+
+## VAT Logic (15% SA rate)
+- Included: Total = sum, Subtotal = Total / 1.15, VAT = Total - Subtotal
+- Excluded: Subtotal = sum, VAT = Subtotal * 0.15, Total = Subtotal + VAT
+- None: Subtotal = Total = sum, VAT = 0