# apps/transactions/admin.py
"""
Django Admin for the Transactions financial domain.
Models registered:
- Transaction : Main financial ledger entry (READ-ONLY)
- TransactionDispute : Dispute management with resolution actions
- TransactionIdempotencyKey: Idempotency tracking (READ-ONLY)
- TransactionFee : Fee breakdown (READ-ONLY inline + standalone)
- TransactionLog : Status transition log (immutable)
- CommissionRule : Platform commission rate configuration
- CompanyRevenueEntry : Revenue accounting (READ-ONLY)
Production rules:
- ALL financial fields are readonly — no manual edits to transaction ledger
- Status badges with colour coding
- Transaction type badges
- Admin disputes can be escalated or resolved via bulk actions
- CSV export action available on Transaction list
2026 features:
- NGN amount formatting helper
- Dispute resolution action (superuser only)
- list_select_related to prevent N+1 on user FKs
"""
import logging
from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.contrib import messages
from django.http import StreamingHttpResponse
import csv
from apps.transactions.models import (
CommissionRule,
CompanyRevenueEntry,
Transaction,
TransactionDispute,
TransactionFee,
TransactionIdempotencyKey,
TransactionLog,
)
logger = logging.getLogger(__name__)
# ── Status colours ────────────────────────────────────────────────────────────
_STATUS_COLOURS = {
"pending": ("#f59e0b", "#fff"),
"processing": ("#3b82f6", "#fff"),
"completed": ("#10b981", "#fff"),
"failed": ("#ef4444", "#fff"),
"cancelled": ("#6b7280", "#fff"),
"reversed": ("#8b5cf6", "#fff"),
"disputed": ("#dc2626", "#fff"),
}
_TYPE_COLOURS = {
"payment": ("#6366f1", "#fff"),
"payout": ("#10b981", "#fff"),
"refund": ("#f59e0b", "#fff"),
"commission": ("#8b5cf6", "#fff"),
"wallet_credit": ("#06b6d4", "#fff"),
"wallet_debit": ("#ef4444", "#fff"),
"escrow_hold": ("#0ea5e9", "#fff"),
"escrow_release": ("#22c55e", "#fff"),
"milestone_payment":("#7c3aed", "#fff"),
}
# ── Inlines ───────────────────────────────────────────────────────────────────
class TransactionFeeInline(admin.TabularInline):
model = TransactionFee
extra = 0
readonly_fields = [
f.name for f in TransactionFee._meta.get_fields()
if hasattr(f, "name")
]
can_delete = False
def has_add_permission(self, request, obj=None):
return False
def has_change_permission(self, request, obj=None):
return False
class TransactionLogInline(admin.TabularInline):
model = TransactionLog
extra = 0
readonly_fields = [
"previous_status", "new_status", "changed_by",
"reason", "metadata", "created_at",
]
can_delete = False
ordering = ["created_at"]
def has_add_permission(self, request, obj=None):
return False
def has_change_permission(self, request, obj=None):
return False
# ── Transaction Admin ─────────────────────────────────────────────────────────
@admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin):
"""
Primary financial ledger admin — ALL fields are read-only.
No manual modification of transaction records is permitted.
"""
list_display = [
"reference", "type_badge", "status_badge",
"formatted_amount", "direction",
"from_user", "to_user", "order_id",
"initiated_at",
]
list_filter = [
"transaction_type", "status", "direction",
]
search_fields = [
"reference", "provider_reference", "idempotency_key",
"order_id", "from_user__email", "to_user__email",
]
ordering = ["-initiated_at"]
date_hierarchy = "initiated_at"
list_select_related = ["from_user", "to_user"]
raw_id_fields = ["from_user", "to_user"]
list_per_page = 25
list_max_show_all = 200
show_full_result_count = False
empty_value_display = "-N/A-"
# ALL financial fields must be read-only — no exceptions
readonly_fields = [
f.name for f in Transaction._meta.get_fields()
if hasattr(f, "name")
]
fieldsets = (
(_("Identity"), {
"fields": (
"id", "reference", "external_reference",
"provider_reference", "idempotency_key",
),
}),
(_("Parties"), {
"fields": ("from_user", "to_user"),
}),
(_("Type & Status"), {
"fields": (
"transaction_type", "direction", "status",
),
}),
(_("Financials"), {
"fields": (
"amount", "fee_amount", "net_amount",
"exchange_rate", "original_amount", "original_currency",
),
}),
(_("Context"), {
"fields": (
"order_id", "custom_order_id", "wallet_id",
"provider", "gateway_response",
),
}),
(_("Timeline"), {
"fields": (
"initiated_at", "processed_at",
"completed_at", "failed_at",
"created_at", "updated_at",
),
"classes": ("collapse",),
}),
(_("Metadata"), {
"fields": ("metadata", "description", "failure_reason"),
"classes": ("collapse",),
}),
)
inlines = [TransactionFeeInline, TransactionLogInline]
actions = ["export_csv"]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
# ── List display helpers ─────────────────────────────────────────────────
@admin.display(description="Status")
def status_badge(self, obj):
bg, fg = _STATUS_COLOURS.get(obj.status, ("#6b7280", "#fff"))
return format_html(
'{}',
bg, fg, obj.get_status_display() if hasattr(obj, "get_status_display") else obj.status,
)
@admin.display(description="Type")
def type_badge(self, obj):
bg, fg = _TYPE_COLOURS.get(obj.transaction_type, ("#6366f1", "#fff"))
label = (
obj.get_transaction_type_display()
if hasattr(obj, "get_transaction_type_display")
else obj.transaction_type
)
return format_html(
'{}',
bg, fg, label,
)
@admin.display(description="Amount (NGN)")
def formatted_amount(self, obj):
return format_html(
'₦{:,.2f}',
obj.amount,
)
# ── CSV Export (streaming) ───────────────────────────────────────────────
@admin.action(description="📥 Export selected transactions to CSV")
def export_csv(self, request, queryset):
fields = [
"reference", "transaction_type", "status", "direction",
"amount", "fee_amount", "currency",
"from_user__email", "to_user__email",
"order_id", "initiated_at", "completed_at",
]
def rows():
yield fields
for row in queryset.values(*fields).iterator():
yield [str(row.get(f, "") or "") for f in fields]
def stream():
import io
for row in rows():
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(row)
yield buf.getvalue()
response = StreamingHttpResponse(stream(), content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="transactions.csv"'
return response
# ── Transaction Dispute Admin ─────────────────────────────────────────────────
@admin.register(TransactionDispute)
class TransactionDisputeAdmin(admin.ModelAdmin):
"""
Dispute management surface.
Actual fields: transaction, initiated_by, status, reason,
disputed_amount, resolved_by, resolved_at, resolution_notes, evidence.
NO currency field on TransactionDispute.
"""
list_display = [
"transaction", "initiated_by", "status_badge",
"disputed_amount", "created_at",
]
list_filter = ["status"]
search_fields = ["transaction__reference", "initiated_by__email", "reason"]
ordering = ["-created_at"]
date_hierarchy = "created_at"
list_select_related = ["transaction", "initiated_by"]
raw_id_fields = ["transaction", "initiated_by"]
list_per_page = 25
show_full_result_count = False
empty_value_display = "-N/A-"
readonly_fields = [
"transaction", "initiated_by", "disputed_amount",
"created_at", "updated_at",
]
fieldsets = (
(_("Dispute"), {
"fields": (
"transaction", "initiated_by",
"disputed_amount", "status",
),
}),
(_("Details"), {
"fields": ("reason", "resolution_notes", "evidence"),
}),
(_("Resolution"), {
"fields": ("resolved_by", "resolved_at"),
}),
(_("Timestamps"), {
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
}),
)
actions = ["action_resolve_disputes"]
@admin.display(description="Status")
def status_badge(self, obj):
colours = {
"opened": ("#f59e0b", "#fff"),
"resolved": ("#10b981", "#fff"),
"escalated": ("#dc2626", "#fff"),
"closed": ("#6b7280", "#fff"),
}
bg, fg = colours.get(obj.status, ("#6b7280", "#fff"))
return format_html(
'{}',
bg, fg, obj.get_status_display() if hasattr(obj, "get_status_display") else obj.status,
)
@admin.action(description="✅ Mark selected disputes as Resolved (superuser only)")
def action_resolve_disputes(self, request, queryset):
if not request.user.is_superuser:
self.message_user(request, "Superuser only.", level=messages.ERROR)
return
from django.utils import timezone
updated = queryset.exclude(status="resolved").update(
status="resolved",
resolved_at=timezone.now(),
resolved_by=request.user,
)
self.message_user(
request,
f"✅ {updated} dispute(s) marked resolved.",
level=messages.SUCCESS,
)
# ── Transaction Fee Admin ─────────────────────────────────────────────────────
@admin.register(TransactionFee)
class TransactionFeeAdmin(admin.ModelAdmin):
"""
Fee breakdown per transaction — read-only.
Actual fields: transaction, fee_type, amount, percentage, description.
NO currency field on TransactionFee.
"""
list_display = ["transaction", "fee_type", "amount", "percentage", "created_at"]
list_filter = ["fee_type"]
search_fields = ["transaction__reference", "fee_type"]
ordering = ["-created_at"]
list_select_related = ["transaction"]
list_per_page = 25
show_full_result_count = False
empty_value_display = "-N/A-"
readonly_fields = [
f.name for f in TransactionFee._meta.get_fields()
if hasattr(f, "name")
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
# ── Transaction Log Admin (append-only) ───────────────────────────────────────
@admin.register(TransactionLog)
class TransactionLogAdmin(admin.ModelAdmin):
list_display = [
"transaction", "previous_status", "new_status",
"changed_by", "created_at",
]
list_filter = ["new_status", "previous_status"]
search_fields = ["transaction__reference", "changed_by__email", "reason"]
ordering = ["-created_at"]
list_select_related = ["transaction", "changed_by"]
date_hierarchy = "created_at"
list_per_page = 25
show_full_result_count = False
empty_value_display = "-N/A-"
readonly_fields = [
f.name for f in TransactionLog._meta.get_fields()
if hasattr(f, "name")
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
# ── Transaction Idempotency Key (read-only) ────────────────────────────────────
@admin.register(TransactionIdempotencyKey)
class TransactionIdempotencyKeyAdmin(admin.ModelAdmin):
list_display = ["key", "transaction", "created_at"]
search_fields = ["key", "transaction__reference"]
ordering = ["-created_at"]
list_select_related = ["transaction"]
date_hierarchy = "created_at"
list_per_page = 25
show_full_result_count = False
empty_value_display = "-N/A-"
readonly_fields = [
f.name for f in TransactionIdempotencyKey._meta.get_fields()
if hasattr(f, "name")
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
# ── Commission Rule Admin ─────────────────────────────────────────────────────
@admin.register(CommissionRule)
class CommissionRuleAdmin(admin.ModelAdmin):
"""
Platform commission rate configuration.
Actual fields: vendor_user (FK), rate, min_rate, max_rate,
is_active, starts_at, ends_at, notes.
"""
list_display = [
"vendor_user", "rate", "min_rate", "max_rate",
"is_active", "starts_at",
]
list_filter = ["is_active"]
search_fields = ["vendor_user__email", "notes"]
ordering = ["-is_active", "-rate"]
list_select_related = ["vendor_user"]
raw_id_fields = ["vendor_user"]
list_per_page = 25
empty_value_display = "-N/A-"
readonly_fields = ["created_at", "updated_at"]
fieldsets = (
(_("Rate"), {
"fields": (
"vendor_user", "rate", "min_rate", "max_rate", "is_active",
),
}),
(_("Validity"), {
"fields": ("starts_at", "ends_at"),
}),
(_("Notes"), {
"fields": ("notes",),
"classes": ("collapse",),
}),
(_("Timestamps"), {
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
}),
)
# ── Company Revenue Entry (read-only) ─────────────────────────────────────────
@admin.register(CompanyRevenueEntry)
class CompanyRevenueEntryAdmin(admin.ModelAdmin):
"""
Company revenue accounting — read-only.
Actual fields: transaction, category, amount, currency (FK),
source_reference, metadata.
"""
list_display = [
"transaction", "amount", "currency", "category",
"source_reference", "created_at",
]
list_filter = ["category"]
search_fields = ["transaction__reference", "source_reference"]
ordering = ["-created_at"]
date_hierarchy = "created_at"
list_select_related = ["transaction", "currency"]
list_per_page = 25
show_full_result_count = False
empty_value_display = "-N/A-"
readonly_fields = [
f.name for f in CompanyRevenueEntry._meta.get_fields()
if hasattr(f, "name")
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False