import json from datetime import datetime, time from functools import wraps from io import BytesIO from django.contrib import messages from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required from django.core.exceptions import ValidationError from django.db.models import Count, Sum from django.http import FileResponse, Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST, require_http_methods from django.utils import timezone from .forms import BusinessProfileForm, LoginForm, ReportFilterForm, SignUpForm, TransactionForm from .jwt_auth import ( JWTAuthError, authenticate_authorization_header, authenticate_refresh_token, issue_token_pair, revoke_user_tokens, ) from .models import BusinessProfile, Transaction def get_api_authenticated_user(request): if request.user.is_authenticated: return request.user authorization = (request.META.get('HTTP_AUTHORIZATION') or '').strip() if not authorization: return None user, _ = authenticate_authorization_header(authorization) request.user = user return user def api_login_required(view_func): @wraps(view_func) def wrapped(request, *args, **kwargs): try: user = get_api_authenticated_user(request) except JWTAuthError as exc: return JsonResponse({ 'ok': False, 'error': exc.message, 'code': exc.code, }, status=exc.status_code) if user is None: return JsonResponse({ 'ok': False, 'error': 'Authentication required.', 'code': 'auth_required', }, status=401) return view_func(request, *args, **kwargs) return wrapped def serialize_transaction(entry): return { 'id': entry.id, 'client_name': entry.client_name, 'amount': str(entry.amount), 'transaction_type': entry.transaction_type, 'transaction_type_label': entry.get_transaction_type_display(), 'service_charge': str(entry.service_charge), 'notes': entry.notes, 'ecash_before': str(entry.ecash_before), 'ecash_after': str(entry.ecash_after), 'physical_before': str(entry.physical_before), 'physical_after': str(entry.physical_after), 'created_by': entry.created_by.username, 'created_at': timezone.localtime(entry.created_at).isoformat(), } def get_profile(user): profile, _ = BusinessProfile.objects.get_or_create(user=user) return profile def build_report_snapshot(profile, params=None): form = ReportFilterForm(params or None) form.is_valid() start_date, end_date = form.get_range() if form.cleaned_data else (timezone.localdate(), timezone.localdate()) start_dt = timezone.make_aware(datetime.combine(start_date, time.min)) end_dt = timezone.make_aware(datetime.combine(end_date, time.max)) entries = profile.transactions.filter(created_at__range=(start_dt, end_dt)).select_related('created_by') summary_rows = entries.values('transaction_type').annotate( total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id'), ) summary_map = {row['transaction_type']: row for row in summary_rows} ordered_summary = [] for value, label in Transaction.TYPE_CHOICES: row = summary_map.get(value) ordered_summary.append({ 'key': value, 'label': label, 'count': row['total_count'] if row else 0, 'amount': row['total_amount'] if row and row['total_amount'] else 0, 'fees': row['total_fees'] if row and row['total_fees'] else 0, }) totals = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id')) latest_entry = entries.order_by('-created_at', '-id').first() closing_ecash = latest_entry.ecash_after if latest_entry else profile.current_ecash closing_physical = latest_entry.physical_after if latest_entry else profile.current_physical_cash return { 'form': form, 'entries': entries.order_by('-created_at', '-id'), 'summary': ordered_summary, 'total_amount': totals['total_amount'] or 0, 'total_fees': totals['total_fees'] or 0, 'total_count': totals['total_count'] or 0, 'start_date': start_date, 'end_date': end_date, 'closing_ecash': closing_ecash, 'closing_physical': closing_physical, } def home(request): context = { 'page_title': 'MoMoLedger | Agent wallet dashboard', 'meta_description': 'Manage MoMo transactions, balances, business branding, and daily-to-yearly reports from one polished dashboard.', 'signup_form': SignUpForm(), 'login_form': LoginForm(request=request), } if request.user.is_authenticated: profile = get_profile(request.user) report_snapshot = build_report_snapshot(profile, {'period': 'daily'}) context.update({ 'profile': profile, 'recent_transactions': profile.transactions.select_related('created_by')[:5], 'today_total': report_snapshot['total_amount'], 'today_fees': report_snapshot['total_fees'], 'today_count': report_snapshot['total_count'], }) return render(request, 'core/index.html', context) def signup_view(request): if request.user.is_authenticated: return redirect('home') form = SignUpForm(request.POST or None) if request.method == 'POST' and form.is_valid(): user = form.save() BusinessProfile.objects.get_or_create(user=user) login(request, user) messages.success(request, 'Welcome! Your account is ready. Set your business details next.') return redirect('profile') return render(request, 'core/auth.html', { 'form': form, 'mode': 'signup', 'page_title': 'Create your MoMo account', 'meta_description': 'Create your mobile money business account and start tracking your balances securely.', }) def login_view(request): if request.user.is_authenticated: return redirect('home') form = LoginForm(request=request, data=request.POST or None) if request.method == 'POST' and form.is_valid(): login(request, form.get_user()) messages.success(request, 'Welcome back to your MoMo dashboard.') return redirect('home') return render(request, 'core/auth.html', { 'form': form, 'mode': 'login', 'page_title': 'Log in to MoMoLedger', 'meta_description': 'Access your mobile money dashboard, balances, transaction log, and reports.', }) @login_required def logout_view(request): logout(request) messages.info(request, 'You have been logged out.') return redirect('home') @login_required def profile_view(request): profile = get_profile(request.user) form = BusinessProfileForm(request.POST or None, request.FILES or None, instance=profile) if request.method == 'POST' and form.is_valid(): profile = form.save() messages.success(request, 'Business profile saved. Your branding will now appear on reports.') return redirect('profile') return render(request, 'core/profile_form.html', { 'form': form, 'profile': profile, 'page_title': 'Business setup | MoMoLedger', 'meta_description': 'Update your business name, logo, and opening wallet balances for branded reports.', }) @login_required def transaction_create_view(request): profile = get_profile(request.user) if not profile.business_name: messages.info(request, 'Start by saving your business profile before logging transactions.') return redirect('profile') form = TransactionForm(request.POST or None, business=profile) if request.method == 'POST' and form.is_valid(): entry = form.save(request.user) messages.success(request, 'Transaction recorded and balances updated automatically.') return redirect('transaction_detail', transaction_id=entry.id) return render(request, 'core/transaction_form.html', { 'form': form, 'profile': profile, 'page_title': 'New transaction | MoMoLedger', 'meta_description': 'Record cash-in, cash-out, sending, airtime, transfer, debt, expenditure, and credit in one place.', }) @login_required def transaction_list_view(request): profile = get_profile(request.user) entries = profile.transactions.select_related('created_by') summary = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id')) return render(request, 'core/transaction_list.html', { 'profile': profile, 'transactions': entries, 'summary': summary, 'page_title': 'Transactions | MoMoLedger', 'meta_description': 'Browse recent mobile money transactions and review balance movements.', }) @login_required def transaction_detail_view(request, transaction_id): profile = get_profile(request.user) entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id) return render(request, 'core/transaction_detail.html', { 'profile': profile, 'transaction': entry, 'page_title': f'{entry.get_transaction_type_display()} details | MoMoLedger', 'meta_description': 'Inspect one MoMo transaction and see the exact e-cash and physical cash balance effect.', }) @login_required def reports_view(request): profile = get_profile(request.user) snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'}) context = { 'profile': profile, 'page_title': 'Reports | MoMoLedger', 'meta_description': 'View printable MoMo business reports by day, week, month, year, or a custom date range.', **snapshot, } return render(request, 'core/reports.html', context) @login_required def report_pdf_view(request): try: from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.units import cm from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfgen import canvas except Exception as exc: # pragma: no cover raise Http404('PDF support is not available.') from exc profile = get_profile(request.user) snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'}) buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 margin = 1.5 * cm y = height - margin pdf.setTitle('MoMo report') pdf.setFillColor(colors.HexColor('#0f6f5c')) pdf.rect(0, height - 4 * cm, width, 4 * cm, fill=1, stroke=0) pdf.setFillColor(colors.white) pdf.setFont('Helvetica-Bold', 18) pdf.drawString(margin, height - 1.5 * cm, profile.business_name or 'MoMoLedger Business') pdf.setFont('Helvetica', 10) owner_text = f"Owner: {profile.owner_label} | User: {request.user.username} | Email: {request.user.email or 'Not set'}" pdf.drawString(margin, height - 2.2 * cm, owner_text[:100]) pdf.drawString(margin, height - 2.8 * cm, f"Report window: {snapshot['start_date']} to {snapshot['end_date']}") if profile.logo: try: pdf.drawImage(profile.logo.path, width - 4.5 * cm, height - 3.2 * cm, width=2.5 * cm, height=2.5 * cm, preserveAspectRatio=True, mask='auto') except Exception: pass y = height - 5.2 * cm pdf.setFillColor(colors.HexColor('#143642')) pdf.setFont('Helvetica-Bold', 12) pdf.drawString(margin, y, 'Balance summary') y -= 0.6 * cm pdf.setFont('Helvetica', 10) metrics = [ f"Opening e-cash: {profile.opening_ecash}", f"Opening physical: {profile.opening_physical_cash}", f"Closing e-cash: {snapshot['closing_ecash']}", f"Closing physical: {snapshot['closing_physical']}", f"Transactions: {snapshot['total_count']}", f"Gross amount: {snapshot['total_amount']}", f"Service fees: {snapshot['total_fees']}", ] for item in metrics: pdf.drawString(margin, y, item) y -= 0.45 * cm y -= 0.2 * cm pdf.setFont('Helvetica-Bold', 12) pdf.drawString(margin, y, 'Transaction type totals') y -= 0.6 * cm pdf.setFont('Helvetica-Bold', 10) pdf.drawString(margin, y, 'Type') pdf.drawString(8.2 * cm, y, 'Count') pdf.drawString(11 * cm, y, 'Amount') pdf.drawString(15 * cm, y, 'Fees') y -= 0.35 * cm pdf.setStrokeColor(colors.HexColor('#d4dfd6')) pdf.line(margin, y, width - margin, y) y -= 0.45 * cm pdf.setFont('Helvetica', 10) for row in snapshot['summary']: if y < 3 * cm: pdf.showPage() y = height - margin pdf.drawString(margin, y, row['label']) pdf.drawString(8.2 * cm, y, str(row['count'])) pdf.drawString(11 * cm, y, str(row['amount'])) pdf.drawString(15 * cm, y, str(row['fees'])) y -= 0.45 * cm y -= 0.3 * cm pdf.setFont('Helvetica-Bold', 12) pdf.drawString(margin, y, 'Recent entries') y -= 0.6 * cm pdf.setFont('Helvetica', 9) for entry in snapshot['entries'][:12]: if y < 2.5 * cm: pdf.showPage() y = height - margin label = f"{timezone.localtime(entry.created_at).strftime('%Y-%m-%d %H:%M')} · {entry.get_transaction_type_display()} · {entry.client_name} · {entry.amount}" max_width = width - (2 * margin) while stringWidth(label, 'Helvetica', 9) > max_width and len(label) > 3: label = label[:-4] + '...' pdf.drawString(margin, y, label) y -= 0.42 * cm pdf.showPage() pdf.save() buffer.seek(0) filename = f"momo-report-{snapshot['start_date']}-to-{snapshot['end_date']}.pdf" return FileResponse(buffer, as_attachment=True, filename=filename) def parse_api_payload(request): if (request.content_type or '').startswith('application/json'): try: return json.loads(request.body.decode('utf-8') or '{}') except (json.JSONDecodeError, UnicodeDecodeError): return None return request.POST def serialize_form_errors(form): errors = {} for field, items in form.errors.get_json_data().items(): errors[field] = [item['message'] for item in items] return errors def build_transaction_summary(profile): summary = profile.transactions.aggregate( total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id'), ) return { 'total_amount': str(summary['total_amount'] or 0), 'total_fees': str(summary['total_fees'] or 0), 'total_count': summary['total_count'] or 0, } @require_GET def api_health_view(request): return JsonResponse({ 'ok': True, 'app': 'MoMoLedger API', 'message': 'Android backend starter endpoints are available.', 'server_time': timezone.now().isoformat(), 'authenticated': bool(request.user.is_authenticated or (request.META.get('HTTP_AUTHORIZATION') or '').strip()), }) @csrf_exempt @require_POST def api_login_view(request): user = request.user if request.user.is_authenticated else None if user is None: payload = parse_api_payload(request) if payload is None: return JsonResponse({ 'ok': False, 'error': 'Invalid JSON body.', }, status=400) username = (payload.get('username') or '').strip() password = payload.get('password') or '' if not username or not password: return JsonResponse({ 'ok': False, 'error': 'Username and password are required.', }, status=400) user = authenticate(request, username=username, password=password) if user is None: return JsonResponse({ 'ok': False, 'error': 'Invalid username or password.', }, status=401) login(request, user) message = 'Login successful.' else: message = 'Already logged in.' profile = get_profile(user) tokens = issue_token_pair(user) return JsonResponse({ 'ok': True, 'message': message, 'user': { 'id': user.id, 'username': user.username, 'email': user.email, 'business_name': profile.business_name, }, 'tokens': tokens, }) @csrf_exempt @require_POST def api_token_refresh_view(request): payload = parse_api_payload(request) if payload is None: return JsonResponse({ 'ok': False, 'error': 'Invalid JSON body.', }, status=400) refresh_token = (payload.get('refresh_token') or payload.get('refresh') or '').strip() try: user, profile = authenticate_refresh_token(refresh_token) except JWTAuthError as exc: return JsonResponse({ 'ok': False, 'error': exc.message, 'code': exc.code, }, status=exc.status_code) return JsonResponse({ 'ok': True, 'message': 'Token refreshed successfully.', 'user': { 'id': user.id, 'username': user.username, 'email': user.email, 'business_name': profile.business_name, }, 'tokens': issue_token_pair(user), }) @csrf_exempt @require_POST def api_logout_view(request): payload = parse_api_payload(request) if payload is None: return JsonResponse({ 'ok': False, 'error': 'Invalid JSON body.', }, status=400) user = request.user if request.user.is_authenticated else None refresh_token = (payload.get('refresh_token') or payload.get('refresh') or '').strip() if user is None and refresh_token: try: user, _ = authenticate_refresh_token(refresh_token) except JWTAuthError as exc: return JsonResponse({ 'ok': False, 'error': exc.message, 'code': exc.code, }, status=exc.status_code) if user is None: authorization = (request.META.get('HTTP_AUTHORIZATION') or '').strip() if authorization: try: user, _ = authenticate_authorization_header(authorization) except JWTAuthError as exc: return JsonResponse({ 'ok': False, 'error': exc.message, 'code': exc.code, }, status=exc.status_code) if user is None: return JsonResponse({ 'ok': True, 'message': 'No active session.', }) revoke_user_tokens(user) if request.user.is_authenticated: logout(request) return JsonResponse({ 'ok': True, 'message': 'Logout successful. Mobile tokens revoked.', }) @require_GET @api_login_required def api_profile_view(request): profile = get_profile(request.user) return JsonResponse({ 'ok': True, 'profile': { 'username': request.user.username, 'email': request.user.email, 'owner_label': profile.owner_label, 'business_name': profile.business_name, 'opening_ecash': str(profile.opening_ecash), 'opening_physical_cash': str(profile.opening_physical_cash), 'current_ecash': str(profile.current_ecash), 'current_physical_cash': str(profile.current_physical_cash), 'total_cash': str(profile.total_cash), 'created_at': timezone.localtime(profile.created_at).isoformat(), 'updated_at': timezone.localtime(profile.updated_at).isoformat(), }, }) @csrf_exempt @api_login_required @require_http_methods(['GET', 'POST']) def api_transactions_view(request): profile = get_profile(request.user) if request.method == 'POST': payload = parse_api_payload(request) if payload is None: return JsonResponse({ 'ok': False, 'error': 'Invalid JSON body.', }, status=400) form = TransactionForm(payload or None, business=profile) if not form.is_valid(): return JsonResponse({ 'ok': False, 'error': 'Validation failed.', 'errors': serialize_form_errors(form), }, status=400) try: entry = form.save(request.user) except ValidationError as exc: return JsonResponse({ 'ok': False, 'error': exc.messages[0] if exc.messages else 'Could not create transaction.', }, status=400) profile.refresh_from_db(fields=['current_ecash', 'current_physical_cash']) return JsonResponse({ 'ok': True, 'message': 'Transaction created successfully.', 'transaction': serialize_transaction(entry), 'balances': { 'current_ecash': str(profile.current_ecash), 'current_physical_cash': str(profile.current_physical_cash), 'total_cash': str(profile.total_cash), }, 'summary': build_transaction_summary(profile), }, status=201) try: limit = int(request.GET.get('limit', 20)) except (TypeError, ValueError): limit = 20 limit = max(1, min(limit, 100)) entries = list(profile.transactions.select_related('created_by')[:limit]) return JsonResponse({ 'ok': True, 'count': len(entries), 'limit': limit, 'summary': build_transaction_summary(profile), 'transactions': [serialize_transaction(entry) for entry in entries], }) @csrf_exempt @api_login_required @require_http_methods(['GET', 'PUT', 'PATCH', 'DELETE']) def api_transaction_detail_view(request, transaction_id): profile = get_profile(request.user) entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id) if request.method == 'GET': return JsonResponse({ 'ok': True, 'transaction': serialize_transaction(entry), 'balances': { 'current_ecash': str(profile.current_ecash), 'current_physical_cash': str(profile.current_physical_cash), 'total_cash': str(profile.total_cash), }, 'summary': build_transaction_summary(profile), }) if request.method in {'PUT', 'PATCH'}: payload = parse_api_payload(request) if payload is None: return JsonResponse({ 'ok': False, 'error': 'Invalid JSON body.', }, status=400) merged_payload = { 'client_name': entry.client_name, 'amount': str(entry.amount), 'transaction_type': entry.transaction_type, 'notes': entry.notes, } merged_payload.update(payload) form = TransactionForm(merged_payload, business=profile, instance=entry) if not form.is_valid(): return JsonResponse({ 'ok': False, 'error': 'Validation failed.', 'errors': serialize_form_errors(form), }, status=400) try: update_result = form.save(request.user) except ValidationError as exc: return JsonResponse({ 'ok': False, 'error': exc.messages[0] if exc.messages else 'Could not update transaction.', }, status=400) profile = update_result['business'] entry = update_result['transaction'] return JsonResponse({ 'ok': True, 'message': 'Transaction updated successfully.', 'transaction': serialize_transaction(entry), 'balances': { 'current_ecash': str(profile.current_ecash), 'current_physical_cash': str(profile.current_physical_cash), 'total_cash': str(profile.total_cash), }, 'summary': build_transaction_summary(profile), }) deleted_transaction = serialize_transaction(entry) try: delete_result = entry.delete_logged_transaction() except ValidationError as exc: return JsonResponse({ 'ok': False, 'error': exc.messages[0] if exc.messages else 'Could not delete transaction.', }, status=400) profile = delete_result['business'] return JsonResponse({ 'ok': True, 'message': 'Transaction deleted successfully.', 'deleted_transaction': deleted_transaction, 'deleted_transaction_id': delete_result['transaction_id'], 'balances': { 'current_ecash': str(profile.current_ecash), 'current_physical_cash': str(profile.current_physical_cash), 'total_cash': str(profile.total_cash), }, 'summary': build_transaction_summary(profile), })