Add expense receipt feature: form, view, templates, email + PDF

Straight port from V2 adapted for V5 field names. Creates expense
receipts with dynamic line items, VAT calculation (Included/Excluded/
None at 15%), and emails HTML + PDF to Spark Receipt. Uses lazy
xhtml2pdf import to avoid crashing if not installed on server.

Files: forms.py (ExpenseReceiptForm + FormSet), views.py (create_receipt),
create_receipt.html, receipt_email.html, receipt_pdf.html, urls.py, base.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-22 21:38:14 +02:00
parent 74cd93fede
commit fc63d972b1
8 changed files with 761 additions and 5 deletions

View File

@ -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'
}),
}
)

View File

@ -63,7 +63,7 @@
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="#">
<a class="nav-link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}" href="{% url 'create_receipt' %}">
<i class="fas fa-receipt me-1"></i> Receipts
</a>
</li>

View File

@ -0,0 +1,326 @@
{% extends 'base.html' %}
{% block title %}Create Receipt | Fox Fitt{% endblock %}
{% block content %}
<!-- === CREATE EXPENSE RECEIPT ===
Single-page form for recording business expenses.
- Dynamic line items (add/remove rows with JavaScript)
- Live VAT calculation (Included / Excluded / None)
- On submit: saves to database + emails HTML + PDF to Spark Receipt -->
<div class="container py-5">
<div class="card border-0 shadow-sm">
<!-- Card header -->
<div class="card-header py-3" style="background-color: var(--primary-color);">
<h4 class="mb-0 text-white fw-bold">
<i class="fas fa-file-invoice-dollar me-2"></i> Create Expense Receipt
</h4>
</div>
<div class="card-body p-4">
<form method="post" id="receipt-form">
{% csrf_token %}
<!-- === RECEIPT HEADER FIELDS === -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-bold text-secondary">Date</label>
{{ form.date }}
</div>
<div class="col-md-6">
<label class="form-label fw-bold text-secondary">Vendor Name</label>
{{ form.vendor_name }}
<div class="form-text text-muted small">
<i class="fas fa-info-circle"></i> Will appear as the main title on the receipt.
</div>
</div>
<div class="col-md-6">
<label class="form-label fw-bold text-secondary">Payment Method</label>
{{ form.payment_method }}
</div>
<div class="col-12">
<label class="form-label fw-bold text-secondary">Description</label>
{{ form.description }}
</div>
</div>
<hr class="my-4">
<!-- === LINE ITEMS SECTION ===
Each row is a product name + amount.
The "Add Line" button adds new rows via JavaScript.
The X button hides the row and checks a hidden DELETE checkbox. -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold text-dark m-0">Items</h5>
<button type="button" id="add-item" class="btn btn-sm btn-outline-secondary rounded-pill">
<i class="fas fa-plus me-1"></i> Add Line
</button>
</div>
<!-- Django formset management form — tracks how many item forms exist -->
{{ items.management_form }}
<div id="items-container">
{% for item_form in items %}
<div class="item-row row g-2 align-items-center mb-2">
<!-- Hidden ID field (used by Django to track existing items) -->
{{ item_form.id }}
<!-- Product name (takes most of the row) -->
<div class="col-12 col-md-7">
{{ item_form.product_name }}
</div>
<!-- Amount with "R" prefix -->
<div class="col-10 col-md-4">
<div class="input-group">
<span class="input-group-text bg-light border-end-0">R</span>
{{ item_form.amount }}
</div>
</div>
<!-- Delete button — hides the row and checks the DELETE checkbox -->
<div class="col-2 col-md-1 text-center">
{% if items.can_delete %}
<div class="form-check d-none">
{{ item_form.DELETE }}
</div>
<button type="button" class="btn btn-outline-danger btn-sm delete-row rounded-circle" title="Remove">
<i class="fas fa-times"></i>
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<hr class="my-4">
<!-- === VAT CONFIGURATION + LIVE TOTALS === -->
<div class="row">
<!-- Left: VAT type radio buttons -->
<div class="col-md-6 mb-3 mb-md-0">
<label class="form-label d-block fw-bold text-secondary mb-2">VAT Configuration (15%)</label>
<div class="card bg-light border-0 p-3">
{% for radio in form.vat_type %}
<div class="form-check mb-2">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Right: Live-updating totals panel -->
<div class="col-md-6">
<label class="form-label d-block fw-bold text-secondary mb-2">Receipt Totals</label>
<div class="p-3 rounded" style="background-color: #f8fafc; border: 1px solid #e2e8f0;">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Subtotal (Excl. VAT):</span>
<span class="fw-bold">R <span id="display-subtotal">0.00</span></span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">VAT (15%):</span>
<span class="fw-bold">R <span id="display-vat">0.00</span></span>
</div>
<div class="d-flex justify-content-between border-top pt-2 mt-2">
<span class="h5 mb-0 fw-bold">Total:</span>
<span class="h5 mb-0" style="color: var(--accent-color);">
R <span id="display-total">0.00</span>
</span>
</div>
</div>
</div>
</div>
<!-- === SUBMIT BUTTON === -->
<div class="text-end mt-4">
<button type="submit" class="btn btn-accent btn-lg">
<i class="fas fa-paper-plane me-2"></i> Create & Send Receipt
</button>
</div>
</form>
</div>
</div>
</div>
<!-- ==========================================================================
JAVASCRIPT — Dynamic line items + live VAT calculation
========================================================================== -->
<script>
(function() {
'use strict';
// --- DOM REFERENCES ---
var itemsContainer = document.getElementById('items-container');
var addItemBtn = document.getElementById('add-item');
var totalForms = document.querySelector('#id_line_items-TOTAL_FORMS');
var displaySubtotal = document.getElementById('display-subtotal');
var displayVat = document.getElementById('display-vat');
var displayTotal = document.getElementById('display-total');
// All VAT radio buttons — we listen for changes on these
var vatRadios = document.querySelectorAll('input[name="vat_type"]');
// === ADD NEW LINE ITEM ROW ===
// When "Add Line" is clicked, build a new blank row using DOM methods.
// We increment TOTAL_FORMS so Django knows there's an extra form.
addItemBtn.addEventListener('click', function() {
var formIdx = parseInt(totalForms.value);
// Create the row container
var row = document.createElement('div');
row.className = 'item-row row g-2 align-items-center mb-2';
// Hidden ID input (required by Django formset)
var hiddenId = document.createElement('input');
hiddenId.type = 'hidden';
hiddenId.name = 'line_items-' + formIdx + '-id';
hiddenId.id = 'id_line_items-' + formIdx + '-id';
row.appendChild(hiddenId);
// Product name column
var prodCol = document.createElement('div');
prodCol.className = 'col-12 col-md-7';
var prodInput = document.createElement('input');
prodInput.type = 'text';
prodInput.name = 'line_items-' + formIdx + '-product_name';
prodInput.className = 'form-control';
prodInput.placeholder = 'Item Name';
prodInput.id = 'id_line_items-' + formIdx + '-product_name';
prodCol.appendChild(prodInput);
row.appendChild(prodCol);
// Amount column with "R" prefix
var amtCol = document.createElement('div');
amtCol.className = 'col-10 col-md-4';
var inputGroup = document.createElement('div');
inputGroup.className = 'input-group';
var prefix = document.createElement('span');
prefix.className = 'input-group-text bg-light border-end-0';
prefix.textContent = 'R';
var amtInput = document.createElement('input');
amtInput.type = 'number';
amtInput.name = 'line_items-' + formIdx + '-amount';
amtInput.className = 'form-control item-amount';
amtInput.step = '0.01';
amtInput.placeholder = '0.00';
amtInput.id = 'id_line_items-' + formIdx + '-amount';
inputGroup.appendChild(prefix);
inputGroup.appendChild(amtInput);
amtCol.appendChild(inputGroup);
row.appendChild(amtCol);
// Delete button column
var delCol = document.createElement('div');
delCol.className = 'col-2 col-md-1 text-center';
var delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn btn-outline-danger btn-sm delete-row rounded-circle';
delBtn.title = 'Remove';
var delIcon = document.createElement('i');
delIcon.className = 'fas fa-times';
delBtn.appendChild(delIcon);
delCol.appendChild(delBtn);
row.appendChild(delCol);
// Add to DOM and update form count
itemsContainer.appendChild(row);
totalForms.value = formIdx + 1;
// Recalculate totals
updateCalculations();
});
// === DELETE LINE ITEM ROW ===
// Uses event delegation — listens on the container for any delete button click.
// If the row has a DELETE checkbox (existing saved item), checks it and hides the row.
// If the row is brand new (no DELETE checkbox), just removes it from the DOM.
itemsContainer.addEventListener('click', function(e) {
var deleteBtn = e.target.closest('.delete-row');
if (!deleteBtn) return;
var row = deleteBtn.closest('.item-row');
var deleteCheckbox = row.querySelector('input[name$="-DELETE"]');
if (deleteCheckbox) {
// Existing item — check DELETE and hide (Django will delete on save)
deleteCheckbox.checked = true;
row.classList.add('d-none', 'deleted');
} else {
// New item — just remove from DOM
row.remove();
}
updateCalculations();
});
// === LIVE AMOUNT INPUT CHANGES ===
// Recalculate whenever an amount field changes
itemsContainer.addEventListener('input', function(e) {
if (e.target.classList.contains('item-amount')) {
updateCalculations();
}
});
// === VAT TYPE RADIO CHANGES ===
vatRadios.forEach(function(radio) {
radio.addEventListener('change', updateCalculations);
});
// === VAT CALCULATION LOGIC ===
// Mirrors the backend Python calculation exactly.
// Three modes: Included (reverse 15%), Excluded (add 15%), None (no VAT).
function updateCalculations() {
// Sum all visible (non-deleted) item amounts
var sum = 0;
var amounts = document.querySelectorAll('.item-row:not(.deleted) .item-amount');
amounts.forEach(function(input) {
var val = parseFloat(input.value) || 0;
sum += val;
});
// Find which VAT radio is selected
var vatType = 'None';
vatRadios.forEach(function(r) {
if (r.checked) vatType = r.value;
});
var subtotal = 0;
var vat = 0;
var total = 0;
if (vatType === 'Included') {
// Entered amounts include VAT — reverse it out
total = sum;
subtotal = total / 1.15;
vat = total - subtotal;
} else if (vatType === 'Excluded') {
// Entered amounts are pre-VAT — add 15% on top
subtotal = sum;
vat = subtotal * 0.15;
total = subtotal + vat;
} else {
// No VAT
subtotal = sum;
vat = 0;
total = sum;
}
// Update the display using textContent (safe, no HTML injection)
displaySubtotal.textContent = subtotal.toFixed(2);
displayVat.textContent = vat.toFixed(2);
displayTotal.textContent = total.toFixed(2);
}
// Run once on page load (in case form has pre-filled values)
updateCalculations();
})();
</script>
{% endblock %}

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<head>
<style>
/* === EMAIL STYLES ===
Email clients have limited CSS support, so we use inline-friendly styles.
Vendor 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; }
.vendor-name { font-size: 24px; font-weight: bold; text-transform: uppercase; color: #000; }
.sub-header { font-size: 14px; color: #666; margin-bottom: 5px; }
.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; }
.total-row { font-size: 18px; font-weight: bold; }
.footer { margin-top: 30px; font-size: 12px; color: #777; text-align: center; border-top: 1px solid #eee; padding-top: 10px; }
</style>
</head>
<body>
<div class="container">
<!-- Header: vendor name is the biggest element -->
<div class="header">
<div class="sub-header">RECEIPT FROM</div>
<div class="vendor-name">{{ receipt.vendor_name }}</div>
</div>
<!-- Receipt details -->
<div class="meta">
<strong>Date:</strong> {{ receipt.date }}<br>
<strong>Payment Method:</strong> {{ receipt.get_payment_method_display }}<br>
<strong>Description:</strong> {{ receipt.description|default:"-" }}
</div>
<!-- Line items table -->
<table class="items-table">
<thead>
<tr>
<th>Item</th>
<th style="text-align: right;">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.product_name }}</td>
<td style="text-align: right;">R {{ item.amount|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div class="totals">
<p>Subtotal: R {{ receipt.subtotal|floatformat:2 }}</p>
<p>VAT ({{ receipt.get_vat_type_display }}): R {{ receipt.vat_amount|floatformat:2 }}</p>
<p class="total-row">Total: R {{ receipt.total_amount|floatformat:2 }}</p>
</div>
<!-- Footer — minimal branding -->
<div class="footer">
<p>Generated by {{ receipt.user.get_full_name|default:receipt.user.username }} via Fox Fitt App</p>
<p>Date Generated: {% now "Y-m-d H:i" %}</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,142 @@
<!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 — vendor name is the dominant element === */
.header {
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 10px;
margin-bottom: 20px;
}
.vendor-name {
font-size: 24pt;
font-weight: bold;
text-transform: uppercase;
color: #000;
}
.sub-header {
font-size: 12pt;
color: #666;
margin-bottom: 5px;
}
/* === META BOX — receipt details === */
.meta {
margin-bottom: 20px;
padding: 10px;
border: 1px solid #ddd;
background-color: #f9f9f9;
}
/* === ITEMS TABLE === */
.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;
}
.total-row {
font-size: 16pt;
font-weight: bold;
border-top: 1px solid #000;
padding-top: 5px;
margin-top: 5px;
}
/* === FOOTER — small print at bottom of page === */
.footer {
text-align: center;
font-size: 10pt;
color: #777;
}
/* Helpers */
.text-right { text-align: right; }
</style>
</head>
<body>
<!-- Header: vendor name is the biggest element -->
<div class="header">
<div class="sub-header">RECEIPT FROM</div>
<div class="vendor-name">{{ receipt.vendor_name }}</div>
</div>
<!-- Receipt details box -->
<div class="meta">
<strong>Date:</strong> {{ receipt.date }}<br>
<strong>Payment Method:</strong> {{ receipt.get_payment_method_display }}<br>
<strong>Description:</strong> {{ receipt.description|default:"-" }}
</div>
<!-- Line items table -->
<table class="items-table">
<thead>
<tr>
<th>Item</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.product_name }}</td>
<td class="text-right">R {{ item.amount|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div class="totals">
<p>Subtotal: R {{ receipt.subtotal|floatformat:2 }}</p>
<p>VAT ({{ receipt.get_vat_type_display }}): R {{ receipt.vat_amount|floatformat:2 }}</p>
<p class="total-row">Total: R {{ receipt.total_amount|floatformat:2 }}</p>
</div>
<!-- Footer frame (positioned at bottom of page by xhtml2pdf) -->
<div id="footerContent" class="footer">
Generated by {{ receipt.user.get_full_name|default:receipt.user.username }} via Fox Fitt App | {% now "Y-m-d H:i" %}
</div>
</body>
</html>

View File

@ -46,6 +46,10 @@ urlpatterns = [
# View a completed payslip (print-friendly page)
path('payroll/payslip/<int:pk>/', 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'),

View File

@ -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.

View File

@ -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