Employees open the submit flow, attach a receipt, and create a new expense in submitted status.
Project — Expense Tracker
Build an expense tracker with receipt upload (MIME validated), status workflow, and manager review page.
What You Are Building
A workspace expense tracker with an approval workflow. Employees submit expense records with a receipt file. Managers (admin role) approve or reject submissions. The system has two views: "My Expenses" for employees and "Review Expenses" for managers.
- Employee / MemberSubmit expenses, upload receipts, review personal history, and delete eligible submitted items.
- Manager / AdminOpen the review queue, approve expenses, or reject them with required review notes.
How this product should work
This needs to read as one full approval system: employee submission, receipt handling, status history, and manager review all need to connect clearly.
The employee-facing page filters personal records and shows whether each submission is waiting, approved, or rejected.
The admin-only review page filters pending records and exposes approve and reject actions with the correct note and confirmation rules.
Receipt files stay attached to the right expense, and deletes must respect status restrictions and storage cleanup rules.
Features to Build
| # | Feature | Est. Time |
|---|---|---|
| 1 | Django: Expense model + migration | 45m |
| 2 | Django: ExpenseSerializer + ExpenseWriteSerializer | 30m |
| 3 | Django: ExpenseService (submit, approve, reject, delete) | 45m |
| 4 | Django: DRF views + URLs + file upload handling | 1h |
| 5 | React: My Expenses page (list + submit form) | 1h 30m |
| 6 | React: Submit expense form with file upload | 1h |
| 7 | React: Manager review page (approve/reject) | 1h |
| 8 | Permissions + all states | 30m |
1. Django Model
Expense
class Expense(models.Model):
class Status(models.TextChoices):
SUBMITTED = 'submitted', 'Submitted'
APPROVED = 'approved', 'Approved'
REJECTED = 'rejected', 'Rejected'
class Category(models.TextChoices):
TRAVEL = 'travel', 'Travel'
MEALS = 'meals', 'Meals'
SOFTWARE = 'software', 'Software'
EQUIPMENT = 'equipment', 'Equipment'
OTHER = 'other', 'Other'
workspace = models.ForeignKey('Workspace', on_delete=models.CASCADE, related_name='expenses')
title = models.CharField(max_length=200)
amount = models.DecimalField(max_digits=10, decimal_places=2)
category = models.CharField(max_length=20, choices=Category.choices)
description = models.TextField(blank=True, default='')
receipt = models.FileField(upload_to='receipts/%Y/%m/', null=True, blank=True)
status = models.CharField(max_length=20, choices=Status.choices,
default=Status.SUBMITTED)
submitted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, related_name='expenses')
reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='reviewed_expenses')
reviewed_at = models.DateTimeField(null=True, blank=True)
review_notes = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'Expense'
verbose_name_plural = 'Expenses'
constraints = [
models.CheckConstraint(
check=models.Q(amount__gt=0),
name='expense_amount_positive'
)
]
def __str__(self):
return f"{self.submitted_by} — {self.title} (${self.amount})"
Run checklist: Django Model
2. Serializers
ExpenseSerializer (read)
fields = ['id', 'title', 'amount', 'category', 'description',
'receipt_url', 'status', 'submitted_by', 'reviewed_by',
'reviewed_at', 'review_notes', 'created_at']
read_only_fields = ['id', 'status', 'submitted_by', 'reviewed_by',
'reviewed_at', 'created_at']
# receipt_url: SerializerMethodField — returns the file URL or None
ExpenseWriteSerializer (write)
fields = ['title', 'amount', 'category', 'description', 'receipt']
# Validation:
# - title: required, max 200 chars
# - amount: required, must be > 0, max 2 decimal places
# - category: required, must be a valid choice
# - receipt: optional, MIME type must be image/jpeg, image/png, or application/pdf, max 10 MB
Run checklist: Django Form / Serializer
3. ExpenseService
class ExpenseService:
@staticmethod
def submit_expense(workspace, title, amount, category,
submitted_by, description='', receipt=None):
"""Creates a new expense in 'submitted' status."""
@staticmethod
def approve_expense(expense, reviewed_by, review_notes=''):
"""
Approves the expense.
Raises ValidationError if status is not 'submitted'.
Sets reviewed_by, reviewed_at, status = 'approved'.
"""
@staticmethod
def reject_expense(expense, reviewed_by, review_notes):
"""
Rejects the expense.
Raises ValidationError if status is not 'submitted'.
review_notes is required for rejection.
Sets reviewed_by, reviewed_at, review_notes, status = 'rejected'.
"""
@staticmethod
def delete_expense(expense, actor):
"""
Deletes the expense and its receipt file from storage.
Only the submitter or an admin can delete.
Cannot delete an approved expense.
"""
4. DRF Views & URLs
Views
ExpenseListCreateView
GET /api/v1/workspaces/{workspace_id}/expenses/
— Returns current user's own expenses
— Filters: ?status=submitted, ?category=travel
— Pagination: 20 per page
POST /api/v1/workspaces/{workspace_id}/expenses/
— Uses multipart/form-data (supports file upload)
— Calls ExpenseService.submit_expense()
— Returns 201
ExpenseDetailView
GET /api/v1/workspaces/{workspace_id}/expenses/{id}/
DELETE /api/v1/workspaces/{workspace_id}/expenses/{id}/
— Only submitter or admin can delete
— Cannot delete approved expense (409)
— Returns 204
ExpenseApproveView
POST /api/v1/workspaces/{workspace_id}/expenses/{id}/approve/
— Admin only
— Calls ExpenseService.approve_expense()
— Returns 200
ExpenseRejectView
POST /api/v1/workspaces/{workspace_id}/expenses/{id}/reject/
— Admin only
— Body: { review_notes: "..." } — required
— Calls ExpenseService.reject_expense()
— Returns 200
AdminExpenseListView
GET /api/v1/workspaces/{workspace_id}/expenses/all/
— Admin only
— Returns ALL workspace expenses
— Filters: ?status=submitted&submitted_by={user_id}
— Pagination: 25 per page
Run checklist: DRF API View
5. React: My Expenses Page
URL: /workspaces/{id}/expenses
HEADER:
[ ] "My Expenses" title + "Submit Expense" button
FILTERS:
[ ] Status: All / Submitted / Approved / Rejected
[ ] Category: All / Travel / Meals / Software / Equipment / Other
[ ] Filter state in URL: ?status=submitted&category=travel
TABLE COLUMNS:
[ ] Title · Amount (right-aligned, 2dp) · Category · Status badge · Submitted On · Actions
STATUS BADGES:
[ ] submitted=blue, approved=green, rejected=red
ACTIONS:
[ ] View receipt link (if receipt exists)
[ ] Delete button — only shown when status = 'submitted' (cannot delete approved)
PAGINATION: 20 per page
STATES:
[ ] Loading: skeleton rows
[ ] Empty: "No expenses yet." + "Submit your first expense" button
[ ] Empty (filter): "No expenses match your filters." + clear button
[ ] Error: "Could not load expenses." + retry
Run checklist: React Page Build · Tables & Data Lists
6. Submit Expense Form (Modal)
Fields:
[ ] Title: text, required, max 200 chars
[ ] Amount: number input, required, min 0.01, 2 decimal places
[ ] Category: dropdown, required
[ ] Description: textarea, optional
[ ] Receipt: file upload — JPG, PNG, PDF only, max 10 MB
File Upload:
[ ] Show accepted types: "JPG, PNG, or PDF up to 10 MB"
[ ] Validate MIME type before submitting (not just extension)
[ ] Show file size error if > 10 MB before upload
[ ] Preview filename + size after selection
[ ] Upload sent as multipart/form-data
Behavior:
[ ] Loading state on submit, button disabled
[ ] On success: "Expense submitted." toast, list refreshes
[ ] On error: banner above submit button (field errors below fields)
[ ] Validation: amount > 0, category selected, file MIME valid
Run checklist: Modals & Dialogs
7. React: Manager Review Page
URL: /workspaces/{id}/expenses/review
[ ] Visible only to admin/owner — 403 state for members
[ ] Default filter: status=submitted (pending review)
[ ] Filters: Status · Employee (dropdown of workspace members) · Category
[ ] Filter chips with X + "Clear all" button
TABLE COLUMNS:
[ ] Employee Name · Title · Amount · Category · Submitted On · Status badge · Actions
ACTIONS (when status = 'submitted'):
[ ] "Approve" (green) · "Reject" (red)
Approve flow:
[ ] Confirmation: "Approve this expense?" + optional note input
[ ] On confirm: calls /approve endpoint, row updates in place
Reject flow:
[ ] Modal: "Reject Expense" with required review_notes textarea
[ ] On submit: calls /reject endpoint, row updates in place
[ ] review_notes required — cannot reject without a reason
After action:
[ ] Approve/Reject buttons replaced by the result badge
[ ] Toast: "Expense approved." / "Expense rejected."
STATES: Loading · Empty · Empty (filter) · Error
Run checklist: Permissions & Role-Based UI · Search, Filters & Pagination
What You Should NOT Do
× Put business logic (approve, reject, delete) in DRF views — use ExpenseService
× Accept receipt file without MIME type validation (check actual MIME, not extension)
× Let a member-role user access /expenses/all/ or approve/reject endpoints
× Let a user delete an approved expense
× Skip the receipt file cleanup when deleting an expense
× Use DecimalField with max_digits < what's needed (use max_digits=10, decimal_places=2)
× Return 200 for a submitted expense (new resource) — use 201
× Accept any file type — only JPG, PNG, PDF