| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- from django.shortcuts import render, redirect, get_object_or_404
- from django.contrib.admin.views.decorators import staff_member_required
- from django.contrib import messages
- from django.views.decorators.http import require_POST
- from django.urls import reverse
- from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
- import django_filters as filters
- from admin_frontend.templatetags.public_urls import public_route
- from admin_frontend.nav import _nav_items
- from cms.views import breadcrumbs
- from orgs.models import Organization
- from recycle_core.models import Customer
- from recycle_core.forms import PaymentForm
- from .models import Invoice, Payment, Payout
- from django.db.models import Sum, F, ExpressionWrapper, DecimalField, Value
- from django.db.models.functions import Coalesce
- from decimal import Decimal
- from django.http import HttpResponse
- import csv
- # Invoices UI ---------------------------------------------------------------
- @staff_member_required
- @public_route(label="Invoices", order=50, icon="receipt-percent")
- @breadcrumbs(label="Invoices", name="re_invoices")
- def invoices_list(request):
- class InvoiceFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
- status = filters.ChoiceFilter(field_name="status", choices=Invoice.STATUS_CHOICES)
- issued_at = filters.DateFromToRangeFilter(field_name="issued_at", label="Issued between")
- class Meta:
- model = Invoice
- fields = ["organization", "customer", "status", "issued_at"]
- base_qs = (
- Invoice.objects.select_related("organization", "customer")
- .annotate(
- payments_total=Coalesce(
- Sum("payments__amount"),
- Value(Decimal("0.00")),
- output_field=DecimalField(max_digits=14, decimal_places=2),
- ),
- )
- .annotate(
- balance=ExpressionWrapper(
- F("total_amount") - F("payments_total"),
- output_field=DecimalField(max_digits=14, decimal_places=2),
- )
- )
- .order_by("-issued_at", "-id")
- )
- i_filter = InvoiceFilter(request.GET, queryset=base_qs)
- qs = i_filter.qs
- org = getattr(request, "org", None)
- if org is not None:
- qs = qs.filter(organization=org)
- paginator = Paginator(qs, 10)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- pay_form = PaymentForm()
- context = {
- "nav": _nav_items(),
- "invoices": page_obj.object_list,
- "page_obj": page_obj,
- "filter": i_filter,
- "pay_form": pay_form,
- }
- return render(request, "recycle_core/invoices_list.html", context)
- @staff_member_required
- def invoice_add_payment(request, pk: int):
- invoice = get_object_or_404(Invoice, pk=pk)
- form = PaymentForm(request.POST)
- if form.is_valid():
- Payment.objects.create(
- invoice=invoice,
- amount=form.cleaned_data["amount"],
- currency_code=invoice.currency_code,
- received_at=form.cleaned_data.get("received_at") or None,
- reference=form.cleaned_data.get("reference", ""),
- )
- messages.success(request, f"Payment recorded for invoice #{invoice.id}.")
- else:
- messages.error(request, "Invalid payment data.")
- return redirect("billing:invoices_list")
- @staff_member_required
- @breadcrumbs(label="Invoice Detail", parent="re_invoices")
- def invoice_detail(request, pk: int):
- inv = get_object_or_404(
- Invoice.objects.select_related("organization", "customer").prefetch_related("lines", "payments"), pk=pk
- )
- # Scope check to current org if applicable
- org = getattr(request, "org", None)
- if org is not None and inv.organization_id != org.id:
- messages.error(request, "Invoice not in this organization.")
- return redirect("billing:invoices_list")
- # Totals
- payments_total = inv.payments.aggregate(s=Coalesce(Sum("amount"), Value(Decimal("0.00"))))["s"] or Decimal("0.00")
- balance = (Decimal(inv.total_amount) - Decimal(payments_total)).quantize(Decimal("0.01"))
- pay_form = PaymentForm()
- context = {
- "nav": _nav_items(),
- "invoice": inv,
- "payments_total": payments_total,
- "balance": balance,
- "pay_form": pay_form,
- "status_choices": Invoice.STATUS_CHOICES,
- }
- return render(request, "recycle_core/invoice_detail.html", context)
- @staff_member_required
- @require_POST
- def invoice_set_status(request, pk: int):
- inv = get_object_or_404(Invoice, pk=pk)
- new_status = (request.POST.get("status") or "").strip()
- valid = {k for k, _ in Invoice.STATUS_CHOICES}
- if new_status not in valid:
- messages.error(request, "Invalid status selection.")
- return redirect("billing:invoice_detail", pk=inv.id)
- inv.status = new_status
- inv.save(update_fields=["status", "updated_at"])
- messages.success(request, f"Invoice status updated to '{new_status}'.")
- return redirect("billing:invoice_detail", pk=inv.id)
- # Payouts UI + CSV ----------------------------------------------------------
- @staff_member_required
- @public_route(label="Payouts", order=52, icon="banknotes")
- @breadcrumbs(label="Payouts", name="re_payouts")
- def payouts_list(request):
- class PayoutFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
- paid_at = filters.DateFromToRangeFilter(field_name="paid_at", label="Paid between")
- class Meta:
- model = Payout
- fields = ["organization", "customer", "paid_at"]
- base_qs = Payout.objects.select_related("organization", "customer").order_by("-paid_at", "-id")
- p_filter = PayoutFilter(request.GET, queryset=base_qs)
- qs = p_filter.qs
- org = getattr(request, "org", None)
- if org is not None:
- qs = qs.filter(organization=org)
- paginator = Paginator(qs, 10)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- context = {
- "nav": _nav_items(),
- "payouts": page_obj.object_list,
- "page_obj": page_obj,
- "filter": p_filter,
- }
- return render(request, "recycle_core/payouts_list.html", context)
- @staff_member_required
- def payouts_export_csv(request):
- qs = Payout.objects.select_related("organization", "customer").all()
- org_obj = getattr(request, "org", None)
- if org_obj is not None:
- qs = qs.filter(organization=org_obj)
- org = request.GET.get("organization")
- cust = request.GET.get("customer")
- start = request.GET.get("paid_at_after") or request.GET.get("paid_at_min")
- end = request.GET.get("paid_at_before") or request.GET.get("paid_at_max")
- from django.utils.dateparse import parse_datetime
- if org:
- qs = qs.filter(organization_id=org)
- if cust:
- qs = qs.filter(customer_id=cust)
- if start:
- dt = parse_datetime(start)
- if dt:
- qs = qs.filter(paid_at__gte=dt)
- if end:
- dt = parse_datetime(end)
- if dt:
- qs = qs.filter(paid_at__lte=dt)
- response = HttpResponse(content_type="text/csv")
- response["Content-Disposition"] = "attachment; filename=payouts.csv"
- writer = csv.writer(response)
- writer.writerow(["organization", "customer", "amount", "currency", "paid_at", "reference", "pickup_id"])
- for p in qs.iterator():
- writer.writerow([
- p.organization.code,
- p.customer.name,
- p.amount,
- p.currency_code,
- p.paid_at.isoformat(),
- p.reference,
- p.pickup_id or "",
- ])
- return response
|