706 lines
25 KiB
Python
706 lines
25 KiB
Python
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),
|
|
})
|