From b07383e862dcab7a9c25cf73efd23f5c0729de4b Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:08:19 -0600 Subject: [PATCH 1/4] feat: add financial overview dashboard --- .../manage/financial_dashboard.html | 261 ++++++++++++++++++ src/django_program/manage/urls.py | 2 + src/django_program/manage/urls_financial.py | 9 + src/django_program/manage/views_financial.py | 173 ++++++++++++ 4 files changed, 445 insertions(+) create mode 100644 src/django_program/manage/templates/django_program/manage/financial_dashboard.html create mode 100644 src/django_program/manage/urls_financial.py create mode 100644 src/django_program/manage/views_financial.py diff --git a/src/django_program/manage/templates/django_program/manage/financial_dashboard.html b/src/django_program/manage/templates/django_program/manage/financial_dashboard.html new file mode 100644 index 0000000..46d047c --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/financial_dashboard.html @@ -0,0 +1,261 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}Financial Overview{% endblock %} + +{% block page_title %} +

Financial Overview

+

Revenue, orders, payments, and ticket sales for {{ conference.name }}

+{% endblock %} + +{% block page_actions %} +View All Orders +{% endblock %} + +{% block content %} + + +
+
+
${{ revenue.net }}
+
Net Revenue
+ {% if revenue.refunded %} +
${{ revenue.total }} gross
+ {% endif %} +
+
+
{{ total_orders }}
+
Total Orders
+ {% if orders_by_status.paid %} +
{{ orders_by_status.paid }} paid
+ {% endif %} +
+
+
{{ carts_by_status.active }}
+
Active Carts
+
+
+
${{ revenue.credits_issued }}
+
Credits Outstanding
+ {% if revenue.credits_applied %} +
${{ revenue.credits_applied }} applied
+ {% endif %} +
+
+ +
+ +
+

Orders by Status

+ + + + + + + + + {% for status, count in orders_by_status.items %} + + + + + {% endfor %} + +
StatusCount
+ {% if status == "paid" %} + Paid + {% elif status == "pending" %} + Pending + {% elif status == "refunded" %} + Refunded + {% elif status == "partially_refunded" %} + Partial Refund + {% elif status == "cancelled" %} + Cancelled + {% else %} + {{ status }} + {% endif %} + {{ count }}
+
+ + +
+

Carts by Status

+ + + + + + + + + + + + + + + + + + + + + + + + + +
StatusCount
Active{{ carts_by_status.active }}
Checked Out{{ carts_by_status.checked_out }}
Expired{{ carts_by_status.expired }}
Abandoned{{ carts_by_status.abandoned }}
+
+
+ + +

Payments by Method

+ + + + + + + + + + {% for method, data in payments_by_method.items %} + + + + + + {% empty %} + + + + {% endfor %} + +
MethodCountTotal Amount
{{ data.label }}{{ data.count }}${{ data.total_amount }}
No payments recorded.
+ + +

Ticket Type Sales

+{% if ticket_sales %} + + + + + + + + + + + + {% for ticket in ticket_sales %} + + + + + + + + {% endfor %} + +
Ticket TypePriceSoldRemainingRevenue
{{ ticket.name }}${{ ticket.price }}{{ ticket.sold_count }} + {% if ticket.total_quantity == 0 %} + ∞ + {% else %} + {{ ticket.remaining_quantity|default:"--" }} + {% endif %} + ${{ ticket.ticket_revenue|default:"0.00" }}
+{% else %} +
+

No ticket types configured for this conference.

+
+{% endif %} + + +

Recent Orders

+{% if recent_orders %} + + + + + + + + + + + + {% for order in recent_orders %} + + + + + + + + {% endfor %} + +
ReferenceUserStatusTotalDate
{{ order.reference }}{{ order.user.email|default:order.user.username }} + {% if order.status == "paid" %} + Paid + {% elif order.status == "pending" %} + Pending + {% elif order.status == "refunded" %} + Refunded + {% elif order.status == "partially_refunded" %} + Partial Refund + {% elif order.status == "cancelled" %} + Cancelled + {% endif %} + ${{ order.total }}{{ order.created_at|date:"M j, Y" }}
+{% else %} +
+

No orders yet for this conference.

+
+{% endif %} + + +

Active Carts

+{% if active_carts %} + + + + + + + + + + + {% for cart in active_carts %} + + + + + + + {% endfor %} + +
UserItemsCreatedExpires
{{ cart.user.email|default:cart.user.username }}{{ cart.item_count }}{{ cart.created_at|date:"M j, Y g:i A" }} + {% if cart.expires_at %} + {{ cart.expires_at|date:"M j, Y g:i A" }} + {% else %} + No expiry + {% endif %} +
+{% else %} +
+

No active carts at this time.

+
+{% endif %} + + +{% endblock %} diff --git a/src/django_program/manage/urls.py b/src/django_program/manage/urls.py index c8dd32b..311405a 100644 --- a/src/django_program/manage/urls.py +++ b/src/django_program/manage/urls.py @@ -197,4 +197,6 @@ ), # --- Voucher Bulk Generation --- path("/vouchers/bulk/", include("django_program.manage.urls_vouchers")), + # --- Financial Dashboard --- + path("/financial/", include("django_program.manage.urls_financial")), ] diff --git a/src/django_program/manage/urls_financial.py b/src/django_program/manage/urls_financial.py new file mode 100644 index 0000000..a99c742 --- /dev/null +++ b/src/django_program/manage/urls_financial.py @@ -0,0 +1,9 @@ +"""URL patterns for the financial overview dashboard.""" + +from django.urls import path + +from django_program.manage.views_financial import FinancialDashboardView + +urlpatterns = [ + path("", FinancialDashboardView.as_view(), name="financial-dashboard"), +] diff --git a/src/django_program/manage/views_financial.py b/src/django_program/manage/views_financial.py new file mode 100644 index 0000000..c16beff --- /dev/null +++ b/src/django_program/manage/views_financial.py @@ -0,0 +1,173 @@ +"""Financial overview dashboard views for conference management. + +Provides revenue summaries, order/cart/payment breakdowns, ticket sales +analytics, and recent transaction listings -- all scoped to the current +conference. +""" + +from decimal import Decimal + +from django.db.models import Count, Q, QuerySet, Sum +from django.utils import timezone +from django.views.generic import TemplateView + +from django_program.manage.views import ManagePermissionMixin +from django_program.registration.models import ( + Cart, + Credit, + Order, + Payment, + TicketType, +) + +_ZERO = Decimal("0.00") + + +class FinancialDashboardView(ManagePermissionMixin, TemplateView): + """Comprehensive financial overview for a conference. + + Computes revenue totals, order/cart/payment breakdowns, ticket sales + analytics, and surfaces recent orders and active carts. All data is + scoped to ``self.conference`` (resolved by ``ManagePermissionMixin``). + """ + + template_name = "django_program/manage/financial_dashboard.html" + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Build context with all financial metrics for the dashboard. + + Args: + **kwargs: Additional context data. + + Returns: + Template context containing revenue, order, cart, payment, + ticket, and credit analytics. + """ + context: dict[str, object] = super().get_context_data(**kwargs) + conference = self.conference + now = timezone.now() + + # --- Revenue --- + revenue_agg = Order.objects.filter( + conference=conference, + status=Order.Status.PAID, + ).aggregate( + total_revenue=Sum("total"), + ) + refunded_agg = Order.objects.filter( + conference=conference, + status__in=[Order.Status.REFUNDED, Order.Status.PARTIALLY_REFUNDED], + ).aggregate( + total_refunded=Sum("total"), + ) + + total_revenue = revenue_agg["total_revenue"] or _ZERO + total_refunded = refunded_agg["total_refunded"] or _ZERO + net_revenue = total_revenue - total_refunded + + credits_agg = Credit.objects.filter(conference=conference).aggregate( + total_issued=Sum("amount"), + total_applied=Sum( + "amount", + filter=Q(status=Credit.Status.APPLIED), + ), + ) + total_credits_issued = credits_agg["total_issued"] or _ZERO + total_credits_applied = credits_agg["total_applied"] or _ZERO + + context["revenue"] = { + "total": total_revenue, + "refunded": total_refunded, + "net": net_revenue, + "credits_issued": total_credits_issued, + "credits_applied": total_credits_applied, + } + + # --- Orders by status --- + order_qs = Order.objects.filter(conference=conference) + orders_by_status: dict[str, int] = {} + for status_value, _label in Order.Status.choices: + orders_by_status[status_value] = order_qs.filter(status=status_value).count() + total_orders = order_qs.count() + context["orders_by_status"] = orders_by_status + context["total_orders"] = total_orders + + # --- Carts by status --- + cart_qs = Cart.objects.filter(conference=conference) + active_cart_count = cart_qs.filter( + Q(status=Cart.Status.OPEN), + Q(expires_at__isnull=True) | Q(expires_at__gt=now), + ).count() + expired_cart_count = cart_qs.filter(status=Cart.Status.EXPIRED).count() + checked_out_cart_count = cart_qs.filter(status=Cart.Status.CHECKED_OUT).count() + abandoned_cart_count = cart_qs.filter(status=Cart.Status.ABANDONED).count() + + context["carts_by_status"] = { + "active": active_cart_count, + "expired": expired_cart_count, + "checked_out": checked_out_cart_count, + "abandoned": abandoned_cart_count, + } + + # --- Payments by method --- + payments_qs = Payment.objects.filter(order__conference=conference) + payments_by_method: dict[str, dict[str, object]] = {} + for method_value, method_label in Payment.Method.choices: + method_agg = payments_qs.filter(method=method_value).aggregate( + count=Count("id"), + total_amount=Sum("amount"), + ) + payments_by_method[method_value] = { + "label": str(method_label), + "count": method_agg["count"] or 0, + "total_amount": method_agg["total_amount"] or _ZERO, + } + context["payments_by_method"] = payments_by_method + + # --- Payments by status --- + payments_by_status: dict[str, int] = {} + for status_value, _label in Payment.Status.choices: + payments_by_status[status_value] = payments_qs.filter(status=status_value).count() + context["payments_by_status"] = payments_by_status + + # --- Ticket sales --- + paid_order_ids = Order.objects.filter( + conference=conference, + status=Order.Status.PAID, + ).values_list("id", flat=True) + + ticket_sales: QuerySet[TicketType] = ( + TicketType.objects.filter(conference=conference) + .annotate( + sold_count=Count( + "order_line_items", + filter=Q(order_line_items__order_id__in=paid_order_ids), + ), + ticket_revenue=Sum( + "order_line_items__line_total", + filter=Q(order_line_items__order_id__in=paid_order_ids), + ), + ) + .order_by("order", "name") + ) + context["ticket_sales"] = ticket_sales + + # --- Recent orders --- + recent_orders = Order.objects.filter(conference=conference).select_related("user").order_by("-created_at")[:20] + context["recent_orders"] = recent_orders + + # --- Active carts --- + active_carts = ( + Cart.objects.filter( + conference=conference, + status=Cart.Status.OPEN, + ) + .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) + .select_related("user") + .annotate(item_count=Count("items")) + .order_by("-created_at") + ) + context["active_carts"] = active_carts + + context["active_nav"] = "financial" + return context From cbf5f86357817f41424315607584d14215f2ba9a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 19:28:56 -0600 Subject: [PATCH 2/4] fix: address PR review feedback and add tests for financial dashboard - Compute refunds from Credit records instead of Order.total (partial refund accuracy) - Aggregate orders_by_status and payments_by_status in single queries - Use Sum(quantity) instead of Count for ticket sold_count, include PARTIALLY_REFUNDED - Show remaining_amount for AVAILABLE credits as "Credits Outstanding" - Replace unreachable {% empty %} on payments_by_method with total_payments check - Add 20 tests for financial dashboard (permissions, revenue, orders, carts, payments, tickets) - Cover ActivityOrganizerMixin unauthenticated redirect for 100% coverage Co-Authored-By: Claude Opus 4.6 --- .../manage/financial_dashboard.html | 12 +- src/django_program/manage/views_financial.py | 58 +- tests/test_manage/test_financial_views.py | 536 ++++++++++++++++++ tests/test_manage/test_programs_views.py | 9 + 4 files changed, 591 insertions(+), 24 deletions(-) create mode 100644 tests/test_manage/test_financial_views.py diff --git a/src/django_program/manage/templates/django_program/manage/financial_dashboard.html b/src/django_program/manage/templates/django_program/manage/financial_dashboard.html index 46d047c..b8ce39d 100644 --- a/src/django_program/manage/templates/django_program/manage/financial_dashboard.html +++ b/src/django_program/manage/templates/django_program/manage/financial_dashboard.html @@ -34,7 +34,7 @@

Financial Overview

Active Carts
-
${{ revenue.credits_issued }}
+
${{ revenue.credits_outstanding }}
Credits Outstanding
{% if revenue.credits_applied %}
${{ revenue.credits_applied }} applied
@@ -112,6 +112,7 @@

Carts by Status

Payments by Method

+{% if total_payments %} @@ -127,13 +128,14 @@

Payments by Method

- {% empty %} - - - {% endfor %}
{{ data.count }} ${{ data.total_amount }}
No payments recorded.
+{% else %} +
+

No payments recorded.

+
+{% endif %}

Ticket Type Sales

diff --git a/src/django_program/manage/views_financial.py b/src/django_program/manage/views_financial.py index c16beff..8e08c9a 100644 --- a/src/django_program/manage/views_financial.py +++ b/src/django_program/manage/views_financial.py @@ -7,7 +7,8 @@ from decimal import Decimal -from django.db.models import Count, Q, QuerySet, Sum +from django.db.models import Count, Q, QuerySet, Sum, Value +from django.db.models.functions import Coalesce from django.utils import timezone from django.views.generic import TemplateView @@ -54,25 +55,36 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: ).aggregate( total_revenue=Sum("total"), ) - refunded_agg = Order.objects.filter( + + # Comment 1: compute refunds from Credit records tied to source orders + # rather than summing Order.total for REFUNDED orders (which overstates + # partial refunds). + refund_agg = Credit.objects.filter( conference=conference, - status__in=[Order.Status.REFUNDED, Order.Status.PARTIALLY_REFUNDED], + source_order__isnull=False, ).aggregate( - total_refunded=Sum("total"), + total_refunded=Sum("amount"), ) total_revenue = revenue_agg["total_revenue"] or _ZERO - total_refunded = refunded_agg["total_refunded"] or _ZERO + total_refunded = refund_agg["total_refunded"] or _ZERO net_revenue = total_revenue - total_refunded + # Comment 4 & 6: "Credits Outstanding" should reflect the remaining + # spendable balance, not the total ever issued. credits_agg = Credit.objects.filter(conference=conference).aggregate( total_issued=Sum("amount"), + total_outstanding=Sum( + "remaining_amount", + filter=Q(status=Credit.Status.AVAILABLE), + ), total_applied=Sum( "amount", filter=Q(status=Credit.Status.APPLIED), ), ) total_credits_issued = credits_agg["total_issued"] or _ZERO + total_credits_outstanding = credits_agg["total_outstanding"] or _ZERO total_credits_applied = credits_agg["total_applied"] or _ZERO context["revenue"] = { @@ -80,15 +92,17 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: "refunded": total_refunded, "net": net_revenue, "credits_issued": total_credits_issued, + "credits_outstanding": total_credits_outstanding, "credits_applied": total_credits_applied, } - # --- Orders by status --- + # --- Orders by status (Comment 7: single aggregated query) --- order_qs = Order.objects.filter(conference=conference) - orders_by_status: dict[str, int] = {} - for status_value, _label in Order.Status.choices: - orders_by_status[status_value] = order_qs.filter(status=status_value).count() - total_orders = order_qs.count() + order_status_rows = order_qs.values("status").annotate(count=Count("id")) + orders_by_status: dict[str, int] = {status_value: 0 for status_value, _label in Order.Status.choices} + for row in order_status_rows: + orders_by_status[row["status"]] = row["count"] + total_orders = sum(orders_by_status.values()) context["orders_by_status"] = orders_by_status context["total_orders"] = total_orders @@ -124,24 +138,30 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: } context["payments_by_method"] = payments_by_method - # --- Payments by status --- - payments_by_status: dict[str, int] = {} - for status_value, _label in Payment.Status.choices: - payments_by_status[status_value] = payments_qs.filter(status=status_value).count() + # --- Payments by status (Comment 2: single aggregated query) --- + payment_status_rows = payments_qs.values("status").annotate(count=Count("id")) + payments_by_status: dict[str, int] = {status_value: 0 for status_value, _label in Payment.Status.choices} + for row in payment_status_rows: + payments_by_status[row["status"]] = row["count"] + total_payments = sum(payments_by_status.values()) context["payments_by_status"] = payments_by_status + context["total_payments"] = total_payments - # --- Ticket sales --- + # --- Ticket sales (Comment 3: Sum of quantity, include PARTIALLY_REFUNDED) --- paid_order_ids = Order.objects.filter( conference=conference, - status=Order.Status.PAID, + status__in=[Order.Status.PAID, Order.Status.PARTIALLY_REFUNDED], ).values_list("id", flat=True) ticket_sales: QuerySet[TicketType] = ( TicketType.objects.filter(conference=conference) .annotate( - sold_count=Count( - "order_line_items", - filter=Q(order_line_items__order_id__in=paid_order_ids), + sold_count=Coalesce( + Sum( + "order_line_items__quantity", + filter=Q(order_line_items__order_id__in=paid_order_ids), + ), + Value(0), ), ticket_revenue=Sum( "order_line_items__line_total", diff --git a/tests/test_manage/test_financial_views.py b/tests/test_manage/test_financial_views.py new file mode 100644 index 0000000..0782050 --- /dev/null +++ b/tests/test_manage/test_financial_views.py @@ -0,0 +1,536 @@ +"""Tests for the financial overview dashboard view.""" + +from datetime import date, timedelta +from decimal import Decimal + +import pytest +from django.contrib.auth.models import User +from django.test import Client +from django.urls import reverse +from django.utils import timezone + +from django_program.conference.models import Conference +from django_program.registration.models import ( + Cart, + Credit, + Order, + OrderLineItem, + Payment, + TicketType, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def superuser(db): + return User.objects.create_superuser(username="admin", password="password", email="admin@test.com") + + +@pytest.fixture +def regular_user(db): + return User.objects.create_user(username="regular", password="password", email="regular@test.com") + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="Test Conf", + slug="test-conf", + start_date=date(2027, 5, 1), + end_date=date(2027, 5, 3), + timezone="UTC", + is_active=True, + ) + + +@pytest.fixture +def client_logged_in_super(superuser): + c = Client() + c.login(username="admin", password="password") + return c + + +@pytest.fixture +def client_logged_in_regular(regular_user): + c = Client() + c.login(username="regular", password="password") + return c + + +def _dashboard_url(conference: Conference) -> str: + return reverse("manage:financial-dashboard", kwargs={"conference_slug": conference.slug}) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestFinancialDashboardPermissions: + """Access control for the financial dashboard.""" + + def test_unauthenticated_redirects_to_login(self, conference): + c = Client() + resp = c.get(_dashboard_url(conference)) + assert resp.status_code == 302 + assert "login" in resp.url + + def test_regular_user_gets_403(self, client_logged_in_regular, conference): + resp = client_logged_in_regular.get(_dashboard_url(conference)) + assert resp.status_code == 403 + + def test_superuser_has_access(self, client_logged_in_super, conference): + resp = client_logged_in_super.get(_dashboard_url(conference)) + assert resp.status_code == 200 + + +@pytest.mark.django_db +class TestFinancialDashboardEmptyConference: + """Dashboard loads correctly when conference has no data.""" + + def test_loads_with_no_data(self, client_logged_in_super, conference): + resp = client_logged_in_super.get(_dashboard_url(conference)) + assert resp.status_code == 200 + + ctx = resp.context + revenue = ctx["revenue"] + assert revenue["total"] == Decimal("0.00") + assert revenue["refunded"] == Decimal("0.00") + assert revenue["net"] == Decimal("0.00") + assert revenue["credits_issued"] == Decimal("0.00") + assert revenue["credits_outstanding"] == Decimal("0.00") + assert revenue["credits_applied"] == Decimal("0.00") + + assert ctx["total_orders"] == 0 + assert all(v == 0 for v in ctx["orders_by_status"].values()) + + assert ctx["carts_by_status"]["active"] == 0 + assert ctx["carts_by_status"]["expired"] == 0 + assert ctx["carts_by_status"]["checked_out"] == 0 + assert ctx["carts_by_status"]["abandoned"] == 0 + + assert ctx["total_payments"] == 0 + assert all(v == 0 for v in ctx["payments_by_status"].values()) + + def test_empty_payments_shows_empty_state(self, client_logged_in_super, conference): + resp = client_logged_in_super.get(_dashboard_url(conference)) + content = resp.content.decode() + assert "No payments recorded." in content + + +@pytest.mark.django_db +class TestFinancialDashboardRevenue: + """Revenue calculations including refund via Credits.""" + + def test_revenue_from_paid_orders(self, client_logged_in_super, conference, superuser): + Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("100.00"), + subtotal=Decimal("100.00"), + reference="ORD-001", + ) + Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("50.00"), + subtotal=Decimal("50.00"), + reference="ORD-002", + ) + resp = client_logged_in_super.get(_dashboard_url(conference)) + revenue = resp.context["revenue"] + assert revenue["total"] == Decimal("150.00") + assert revenue["net"] == Decimal("150.00") + + def test_refunded_amount_from_credits(self, client_logged_in_super, conference, superuser): + paid = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("200.00"), + subtotal=Decimal("200.00"), + reference="ORD-PAID", + ) + refunded = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.REFUNDED, + total=Decimal("100.00"), + subtotal=Decimal("100.00"), + reference="ORD-REF", + ) + # Partial refund: $30 credit issued from that refunded order + Credit.objects.create( + user=superuser, + conference=conference, + amount=Decimal("30.00"), + status=Credit.Status.AVAILABLE, + source_order=refunded, + ) + + resp = client_logged_in_super.get(_dashboard_url(conference)) + revenue = resp.context["revenue"] + assert revenue["total"] == Decimal("200.00") + assert revenue["refunded"] == Decimal("30.00") + assert revenue["net"] == Decimal("170.00") + + def test_credits_outstanding_uses_remaining_amount(self, client_logged_in_super, conference, superuser): + order = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.REFUNDED, + total=Decimal("50.00"), + subtotal=Decimal("50.00"), + reference="ORD-CR", + ) + # Available credit with partial spend + credit = Credit.objects.create( + user=superuser, + conference=conference, + amount=Decimal("50.00"), + status=Credit.Status.AVAILABLE, + source_order=order, + ) + # remaining_amount is initialized by save() to match amount; simulate partial use + credit.remaining_amount = Decimal("20.00") + credit.save() + + # Applied credit + Credit.objects.create( + user=superuser, + conference=conference, + amount=Decimal("10.00"), + remaining_amount=Decimal("0.00"), + status=Credit.Status.APPLIED, + source_order=order, + ) + + resp = client_logged_in_super.get(_dashboard_url(conference)) + revenue = resp.context["revenue"] + assert revenue["credits_outstanding"] == Decimal("20.00") + assert revenue["credits_applied"] == Decimal("10.00") + assert revenue["credits_issued"] == Decimal("60.00") + + def test_credits_outstanding_in_template(self, client_logged_in_super, conference, superuser): + order = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.REFUNDED, + total=Decimal("75.00"), + subtotal=Decimal("75.00"), + reference="ORD-TMPL", + ) + Credit.objects.create( + user=superuser, + conference=conference, + amount=Decimal("75.00"), + status=Credit.Status.AVAILABLE, + source_order=order, + ) + resp = client_logged_in_super.get(_dashboard_url(conference)) + content = resp.content.decode() + assert "$75.00" in content + assert "Credits Outstanding" in content + + +@pytest.mark.django_db +class TestFinancialDashboardOrdersByStatus: + """Order breakdown by status uses aggregated query.""" + + def test_orders_counted_by_status(self, client_logged_in_super, conference, superuser): + Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("10.00"), + subtotal=Decimal("10.00"), + reference="ORD-P1", + ) + Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("20.00"), + subtotal=Decimal("20.00"), + reference="ORD-P2", + ) + Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PENDING, + total=Decimal("15.00"), + subtotal=Decimal("15.00"), + reference="ORD-PE1", + ) + Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.CANCELLED, + total=Decimal("5.00"), + subtotal=Decimal("5.00"), + reference="ORD-C1", + ) + resp = client_logged_in_super.get(_dashboard_url(conference)) + ctx = resp.context + assert ctx["orders_by_status"]["paid"] == 2 + assert ctx["orders_by_status"]["pending"] == 1 + assert ctx["orders_by_status"]["cancelled"] == 1 + assert ctx["orders_by_status"]["refunded"] == 0 + assert ctx["orders_by_status"]["partially_refunded"] == 0 + assert ctx["total_orders"] == 4 + + +@pytest.mark.django_db +class TestFinancialDashboardCartsByStatus: + """Cart breakdown by status.""" + + def test_active_carts_filter(self, client_logged_in_super, conference, superuser): + now = timezone.now() + # Active: open, no expiry + Cart.objects.create( + conference=conference, + user=superuser, + status=Cart.Status.OPEN, + ) + # Active: open, expires in the future + Cart.objects.create( + conference=conference, + user=superuser, + status=Cart.Status.OPEN, + expires_at=now + timedelta(hours=1), + ) + # Not active: open but expired + Cart.objects.create( + conference=conference, + user=superuser, + status=Cart.Status.OPEN, + expires_at=now - timedelta(hours=1), + ) + # Expired status + Cart.objects.create( + conference=conference, + user=superuser, + status=Cart.Status.EXPIRED, + ) + # Checked out + Cart.objects.create( + conference=conference, + user=superuser, + status=Cart.Status.CHECKED_OUT, + ) + # Abandoned + Cart.objects.create( + conference=conference, + user=superuser, + status=Cart.Status.ABANDONED, + ) + + resp = client_logged_in_super.get(_dashboard_url(conference)) + carts = resp.context["carts_by_status"] + assert carts["active"] == 2 + assert carts["expired"] == 1 + assert carts["checked_out"] == 1 + assert carts["abandoned"] == 1 + + def test_active_carts_list(self, client_logged_in_super, conference, superuser): + Cart.objects.create( + conference=conference, + user=superuser, + status=Cart.Status.OPEN, + ) + resp = client_logged_in_super.get(_dashboard_url(conference)) + active_carts = resp.context["active_carts"] + assert len(active_carts) == 1 + + +@pytest.mark.django_db +class TestFinancialDashboardPayments: + """Payment breakdown by status and method.""" + + def test_payments_by_status(self, client_logged_in_super, conference, superuser): + order = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("100.00"), + subtotal=Decimal("100.00"), + reference="ORD-PAY", + ) + Payment.objects.create( + order=order, + method=Payment.Method.STRIPE, + status=Payment.Status.SUCCEEDED, + amount=Decimal("100.00"), + ) + Payment.objects.create( + order=order, + method=Payment.Method.MANUAL, + status=Payment.Status.PENDING, + amount=Decimal("25.00"), + ) + + resp = client_logged_in_super.get(_dashboard_url(conference)) + ctx = resp.context + assert ctx["payments_by_status"]["succeeded"] == 1 + assert ctx["payments_by_status"]["pending"] == 1 + assert ctx["total_payments"] == 2 + + def test_payments_by_method(self, client_logged_in_super, conference, superuser): + order = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("100.00"), + subtotal=Decimal("100.00"), + reference="ORD-PM", + ) + Payment.objects.create( + order=order, + method=Payment.Method.STRIPE, + status=Payment.Status.SUCCEEDED, + amount=Decimal("80.00"), + ) + Payment.objects.create( + order=order, + method=Payment.Method.CREDIT, + status=Payment.Status.SUCCEEDED, + amount=Decimal("20.00"), + ) + + resp = client_logged_in_super.get(_dashboard_url(conference)) + methods = resp.context["payments_by_method"] + assert methods["stripe"]["count"] == 1 + assert methods["stripe"]["total_amount"] == Decimal("80.00") + assert methods["credit"]["count"] == 1 + assert methods["credit"]["total_amount"] == Decimal("20.00") + + def test_payments_table_shown_when_payments_exist(self, client_logged_in_super, conference, superuser): + order = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("50.00"), + subtotal=Decimal("50.00"), + reference="ORD-TBL", + ) + Payment.objects.create( + order=order, + method=Payment.Method.STRIPE, + status=Payment.Status.SUCCEEDED, + amount=Decimal("50.00"), + ) + resp = client_logged_in_super.get(_dashboard_url(conference)) + content = resp.content.decode() + assert "No payments recorded." not in content + + +@pytest.mark.django_db +class TestFinancialDashboardTicketSales: + """Ticket sales annotations use Sum of quantity and include PARTIALLY_REFUNDED.""" + + def test_sold_count_uses_quantity(self, client_logged_in_super, conference, superuser): + ticket = TicketType.objects.create( + conference=conference, + name="General", + slug="general", + price=Decimal("50.00"), + order=0, + ) + order = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("150.00"), + subtotal=Decimal("150.00"), + reference="ORD-TIX", + ) + OrderLineItem.objects.create( + order=order, + description="General", + quantity=3, + unit_price=Decimal("50.00"), + line_total=Decimal("150.00"), + ticket_type=ticket, + ) + + resp = client_logged_in_super.get(_dashboard_url(conference)) + sales = list(resp.context["ticket_sales"]) + assert len(sales) == 1 + assert sales[0].sold_count == 3 + assert sales[0].ticket_revenue == Decimal("150.00") + + def test_partially_refunded_orders_included(self, client_logged_in_super, conference, superuser): + ticket = TicketType.objects.create( + conference=conference, + name="VIP", + slug="vip", + price=Decimal("100.00"), + order=0, + ) + order = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PARTIALLY_REFUNDED, + total=Decimal("200.00"), + subtotal=Decimal("200.00"), + reference="ORD-PR", + ) + OrderLineItem.objects.create( + order=order, + description="VIP", + quantity=2, + unit_price=Decimal("100.00"), + line_total=Decimal("200.00"), + ticket_type=ticket, + ) + + resp = client_logged_in_super.get(_dashboard_url(conference)) + sales = list(resp.context["ticket_sales"]) + assert len(sales) == 1 + assert sales[0].sold_count == 2 + + def test_ticket_with_no_sales_shows_zero(self, client_logged_in_super, conference): + TicketType.objects.create( + conference=conference, + name="Student", + slug="student", + price=Decimal("25.00"), + order=0, + ) + resp = client_logged_in_super.get(_dashboard_url(conference)) + sales = list(resp.context["ticket_sales"]) + assert len(sales) == 1 + assert sales[0].sold_count == 0 + + +@pytest.mark.django_db +class TestFinancialDashboardRecentOrders: + """Recent orders limited to 20.""" + + def test_recent_orders_limited_to_20(self, client_logged_in_super, conference, superuser): + for i in range(25): + Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("10.00"), + subtotal=Decimal("10.00"), + reference=f"ORD-RECENT-{i:03d}", + ) + resp = client_logged_in_super.get(_dashboard_url(conference)) + assert len(resp.context["recent_orders"]) == 20 + + +@pytest.mark.django_db +class TestFinancialDashboardURLResolution: + """URL pattern resolves correctly.""" + + def test_financial_dashboard_url(self): + url = reverse("manage:financial-dashboard", kwargs={"conference_slug": "test-conf"}) + assert url == "/manage/test-conf/financial/" diff --git a/tests/test_manage/test_programs_views.py b/tests/test_manage/test_programs_views.py index de258d5..588b38c 100644 --- a/tests/test_manage/test_programs_views.py +++ b/tests/test_manage/test_programs_views.py @@ -544,6 +544,15 @@ def test_disbursed_grant_shows_info_on_review(authed_client: Client, conference, # ---- Activity Dashboard views ---- +@pytest.mark.django_db +def test_activity_dashboard_unauthenticated_redirects(conference, activity): + c = Client() + url = reverse("manage:activity-dashboard", kwargs={"conference_slug": conference.slug, "pk": activity.pk}) + response = c.get(url) + assert response.status_code == 302 + assert "login" in response.url + + @pytest.mark.django_db def test_activity_dashboard_get(authed_client: Client, conference, activity, regular_user): ActivitySignup.objects.create(activity=activity, user=regular_user, status=ActivitySignup.SignupStatus.CONFIRMED) From 0aacd901c9552e2eca627d738b0d576c358287ea Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 13 Feb 2026 22:23:33 -0600 Subject: [PATCH 3/4] fix: address remaining PR review comments for financial dashboard Fix payments_by_method N+1 queries by using a single aggregated query with values().annotate() instead of per-method filter().aggregate(). Add sidebar navigation entry for the financial dashboard under the Registration section with active_nav highlight support. Co-Authored-By: Claude Opus 4.6 --- .../templates/django_program/manage/base.html | 5 +++ src/django_program/manage/views_financial.py | 22 +++++----- tests/test_manage/test_financial_views.py | 44 +++++++++++++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/django_program/manage/templates/django_program/manage/base.html b/src/django_program/manage/templates/django_program/manage/base.html index 7e9c71f..acd0bf4 100644 --- a/src/django_program/manage/templates/django_program/manage/base.html +++ b/src/django_program/manage/templates/django_program/manage/base.html @@ -1050,6 +1050,11 @@