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 %} 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 %} + + +
+
+ + +
+

+ Create Expense Receipt +

+
+ +
+
+ {% csrf_token %} + + +
+
+ + {{ form.date }} +
+
+ + {{ form.vendor_name }} +
+ Will appear as the main title on the receipt. +
+
+
+ + {{ form.payment_method }} +
+
+ + {{ form.description }} +
+
+ +
+ + + +
+
Items
+ +
+ + + {{ items.management_form }} + +
+ {% for item_form in items %} +
+ + {{ item_form.id }} + + +
+ {{ item_form.product_name }} +
+ + +
+
+ R + {{ item_form.amount }} +
+
+ + +
+ {% if items.can_delete %} +
+ {{ item_form.DELETE }} +
+ + {% endif %} +
+
+ {% endfor %} +
+ +
+ + +
+ +
+ +
+ {% for radio in form.vat_type %} +
+ {{ radio.tag }} + +
+ {% endfor %} +
+
+ + +
+ +
+
+ Subtotal (Excl. VAT): + R 0.00 +
+
+ VAT (15%): + R 0.00 +
+
+ Total: + + R 0.00 + +
+
+
+
+ + +
+ +
+
+
+
+
+ + + +{% 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 @@ + + + + + + +
+ +
+
RECEIPT FROM
+
{{ receipt.vendor_name }}
+
+ + +
+ Date: {{ receipt.date }}
+ Payment Method: {{ receipt.get_payment_method_display }}
+ Description: {{ receipt.description|default:"-" }} +
+ + + + + + + + + + + {% for item in items %} + + + + + {% endfor %} + +
ItemAmount
{{ item.product_name }}R {{ item.amount|floatformat:2 }}
+ + +
+

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 @@ + + + + + + + + +
+
RECEIPT FROM
+
{{ receipt.vendor_name }}
+
+ + +
+ Date: {{ receipt.date }}
+ Payment Method: {{ receipt.get_payment_method_display }}
+ Description: {{ receipt.description|default:"-" }} +
+ + + + + + + + + + + {% for item in items %} + + + + + {% endfor %} + +
ItemAmount
{{ item.product_name }}R {{ item.amount|floatformat:2 }}
+ + +
+

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