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:
parent
74cd93fede
commit
fc63d972b1
@ -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'
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
326
core/templates/core/create_receipt.html
Normal file
326
core/templates/core/create_receipt.html
Normal 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 %}
|
||||
70
core/templates/core/email/receipt_email.html
Normal file
70
core/templates/core/email/receipt_email.html
Normal 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>
|
||||
142
core/templates/core/pdf/receipt_pdf.html
Normal file
142
core/templates/core/pdf/receipt_pdf.html
Normal 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>
|
||||
@ -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'),
|
||||
|
||||
132
core/views.py
132
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.
|
||||
|
||||
28
docs/plans/2026-02-22-expense-receipt-design.md
Normal file
28
docs/plans/2026-02-22-expense-receipt-design.md
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user