Autosave: 20260417-025218

This commit is contained in:
Flatlogic Bot 2026-04-17 02:52:16 +00:00
parent 640537c868
commit 22e473eb14
30 changed files with 2749 additions and 243 deletions

View File

@ -37,17 +37,11 @@ CSRF_TRUSTED_ORIGINS = [
for host in CSRF_TRUSTED_ORIGINS
]
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SAMESITE = "None"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
@ -65,8 +59,6 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
X_FRAME_OPTIONS = 'ALLOWALL'
@ -83,7 +75,6 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# IMPORTANT: do not remove injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
'core.context_processors.project_context',
],
},
@ -92,10 +83,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
@ -110,10 +97,6 @@ DATABASES = {
},
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -129,33 +112,22 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR / 'static',
BASE_DIR / 'assets',
BASE_DIR / 'node_modules',
]
# Email
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend"
@ -173,10 +145,11 @@ CONTACT_EMAIL_TO = [
if item.strip()
]
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL:
EMAIL_USE_TLS = False
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'home'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View File

@ -1,23 +1,7 @@
"""
URL configuration for config project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
@ -25,5 +9,6 @@ urlpatterns = [
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,17 @@
from django.contrib import admin
# Register your models here.
from .models import BusinessProfile, Transaction
@admin.register(BusinessProfile)
class BusinessProfileAdmin(admin.ModelAdmin):
list_display = ('business_name', 'user', 'opening_ecash', 'opening_physical_cash', 'current_ecash', 'current_physical_cash')
search_fields = ('business_name', 'user__username', 'user__email')
@admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin):
list_display = ('client_name', 'transaction_type', 'amount', 'service_charge', 'business', 'created_at')
list_filter = ('transaction_type', 'created_at')
search_fields = ('client_name', 'business__business_name', 'created_by__username')
date_hierarchy = 'created_at'

154
core/forms.py Normal file
View File

@ -0,0 +1,154 @@
from decimal import Decimal
from django import forms
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.utils import timezone
from .models import BusinessProfile, Transaction
INPUT_CLASS = 'form-control form-control-lg momo-input'
SELECT_CLASS = 'form-select form-select-lg momo-input'
class SignUpForm(UserCreationForm):
first_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'First name'}))
last_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Last name'}))
email = forms.EmailField(required=True, widget=forms.EmailInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Email address'}))
class Meta(UserCreationForm.Meta):
model = User
fields = ('first_name', 'last_name', 'username', 'email')
widgets = {
'username': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['username'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Username'})
self.fields['password1'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Password'})
self.fields['password2'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Confirm password'})
def save(self, commit=True):
user = super().save(commit=False)
user.first_name = self.cleaned_data['first_name']
user.last_name = self.cleaned_data['last_name']
user.email = self.cleaned_data['email']
if commit:
user.save()
return user
class LoginForm(AuthenticationForm):
username = forms.CharField(widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'}))
password = forms.CharField(widget=forms.PasswordInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Password'}))
class BusinessProfileForm(forms.ModelForm):
class Meta:
model = BusinessProfile
fields = ['business_name', 'logo', 'opening_ecash', 'opening_physical_cash']
widgets = {
'business_name': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'e.g. BrightPay MoMo Point'}),
'logo': forms.ClearableFileInput(attrs={'class': 'form-control momo-file', 'accept': 'image/*'}),
'opening_ecash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}),
'opening_physical_cash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}),
}
help_texts = {
'opening_ecash': 'Used as your starting e-cash wallet for the first setup.',
'opening_physical_cash': 'Used as your starting notes/coins balance for the first setup.',
}
def save(self, commit=True):
profile = super().save(commit=False)
should_sync = not profile.pk or not profile.transactions.exists()
if should_sync:
profile.sync_current_to_opening()
if commit:
profile.save()
return profile
class TransactionForm(forms.Form):
client_name = forms.CharField(max_length=120, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Client name'}))
amount = forms.DecimalField(max_digits=12, decimal_places=2, min_value=Decimal('0.01'), widget=forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0.01'}))
transaction_type = forms.ChoiceField(choices=Transaction.TYPE_CHOICES, widget=forms.Select(attrs={'class': SELECT_CLASS}))
notes = forms.CharField(required=False, widget=forms.Textarea(attrs={'class': 'form-control momo-input', 'rows': 3, 'placeholder': 'Optional note about the transaction'}))
def __init__(self, *args, business=None, **kwargs):
self.business = business
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
transaction_type = cleaned_data.get('transaction_type')
amount = cleaned_data.get('amount')
if not self.business or not transaction_type or amount is None:
return cleaned_data
ecash_delta, physical_delta, _ = Transaction.calculate_effect(transaction_type, amount)
if self.business.current_ecash + ecash_delta < 0:
self.add_error('amount', 'Not enough e-cash for this transaction.')
if self.business.current_physical_cash + physical_delta < 0:
self.add_error('amount', 'Not enough physical cash for this transaction.')
return cleaned_data
def save(self, user):
if not self.business:
raise ValidationError('Business profile is required.')
return Transaction.create_logged_transaction(
business=self.business,
user=user,
client_name=self.cleaned_data['client_name'],
amount=self.cleaned_data['amount'],
transaction_type=self.cleaned_data['transaction_type'],
notes=self.cleaned_data['notes'],
)
class ReportFilterForm(forms.Form):
PERIOD_CHOICES = [
('daily', 'Daily'),
('weekly', 'Weekly'),
('monthly', 'Monthly'),
('yearly', 'Yearly'),
('custom', 'Custom range'),
]
period = forms.ChoiceField(choices=PERIOD_CHOICES, initial='daily', widget=forms.Select(attrs={'class': 'form-select momo-input'}))
start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'}))
end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'}))
def clean(self):
cleaned_data = super().clean()
period = cleaned_data.get('period')
start_date = cleaned_data.get('start_date')
end_date = cleaned_data.get('end_date')
if period == 'custom':
if not start_date or not end_date:
raise forms.ValidationError('Choose both a start and end date for a custom report.')
if end_date < start_date:
raise forms.ValidationError('End date cannot be before start date.')
return cleaned_data
def get_range(self):
today = timezone.localdate()
period = self.cleaned_data.get('period') or 'daily'
if period == 'daily':
return today, today
if period == 'weekly':
start = today - timezone.timedelta(days=today.weekday())
return start, start + timezone.timedelta(days=6)
if period == 'monthly':
start = today.replace(day=1)
if start.month == 12:
next_month = start.replace(year=start.year + 1, month=1, day=1)
else:
next_month = start.replace(month=start.month + 1, day=1)
return start, next_month - timezone.timedelta(days=1)
if period == 'yearly':
start = today.replace(month=1, day=1)
return start, today.replace(month=12, day=31)
return self.cleaned_data['start_date'], self.cleaned_data['end_date']

View File

@ -0,0 +1,57 @@
# Generated by Django 5.2.7 on 2026-04-17 02:21
import django.db.models.deletion
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BusinessProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('business_name', models.CharField(blank=True, max_length=120)),
('logo', models.FileField(blank=True, null=True, upload_to='business_logos/')),
('opening_ecash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
('opening_physical_cash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
('current_ecash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
('current_physical_cash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='business_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['user__username'],
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('client_name', models.CharField(max_length=120)),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('transaction_type', models.CharField(choices=[('cash_out', 'Cash-out'), ('cash_in', 'Cash-In'), ('sending', 'Sending'), ('airtime', 'Airtime'), ('transfer', 'Transfer'), ('debt', 'Debt'), ('expenditure', 'Expenditure'), ('credit', 'Credit')], max_length=20)),
('service_charge', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
('notes', models.CharField(blank=True, max_length=255)),
('ecash_before', models.DecimalField(decimal_places=2, max_digits=12)),
('ecash_after', models.DecimalField(decimal_places=2, max_digits=12)),
('physical_before', models.DecimalField(decimal_places=2, max_digits=12)),
('physical_after', models.DecimalField(decimal_places=2, max_digits=12)),
('created_at', models.DateTimeField(auto_now_add=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='core.businessprofile')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='momo_transactions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-id'],
},
),
]

View File

@ -1,3 +1,196 @@
from django.db import models
from decimal import Decimal, ROUND_HALF_UP
# Create your models here.
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models, transaction
class BusinessProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='business_profile')
business_name = models.CharField(max_length=120, blank=True)
logo = models.FileField(upload_to='business_logos/', blank=True, null=True)
opening_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
opening_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
current_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
current_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['user__username']
def __str__(self):
return self.business_name or f"{self.user.username}'s MoMo Business"
@property
def owner_label(self):
return self.user.get_full_name() or self.user.username
@property
def total_cash(self):
return (self.current_ecash or Decimal('0.00')) + (self.current_physical_cash or Decimal('0.00'))
def sync_current_to_opening(self):
self.current_ecash = self.opening_ecash
self.current_physical_cash = self.opening_physical_cash
class Transaction(models.Model):
CASH_OUT = 'cash_out'
CASH_IN = 'cash_in'
SENDING = 'sending'
AIRTIME = 'airtime'
TRANSFER = 'transfer'
DEBT = 'debt'
EXPENDITURE = 'expenditure'
CREDIT = 'credit'
TYPE_CHOICES = [
(CASH_OUT, 'Cash-out'),
(CASH_IN, 'Cash-In'),
(SENDING, 'Sending'),
(AIRTIME, 'Airtime'),
(TRANSFER, 'Transfer'),
(DEBT, 'Debt'),
(EXPENDITURE, 'Expenditure'),
(CREDIT, 'Credit'),
]
business = models.ForeignKey(BusinessProfile, on_delete=models.CASCADE, related_name='transactions')
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='momo_transactions')
client_name = models.CharField(max_length=120)
amount = models.DecimalField(max_digits=12, decimal_places=2)
transaction_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
service_charge = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
notes = models.CharField(max_length=255, blank=True)
ecash_before = models.DecimalField(max_digits=12, decimal_places=2)
ecash_after = models.DecimalField(max_digits=12, decimal_places=2)
physical_before = models.DecimalField(max_digits=12, decimal_places=2)
physical_after = models.DecimalField(max_digits=12, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at', '-id']
def __str__(self):
return f"{self.get_transaction_type_display()} · {self.client_name} · {self.amount}"
@staticmethod
def _round(value: Decimal) -> Decimal:
return value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
@classmethod
def calculate_effect(cls, transaction_type: str, amount: Decimal) -> tuple[Decimal, Decimal, Decimal]:
amount = cls._round(amount)
fee = Decimal('0.00')
ecash_delta = Decimal('0.00')
physical_delta = Decimal('0.00')
if transaction_type == cls.CASH_IN:
ecash_delta = -amount
physical_delta = amount
elif transaction_type == cls.CASH_OUT:
ecash_delta = amount
physical_delta = -amount
elif transaction_type in {cls.AIRTIME, cls.TRANSFER}:
ecash_delta = -amount
physical_delta = amount
elif transaction_type == cls.SENDING:
fee = cls._round(amount * Decimal('0.01'))
ecash_delta = -amount
physical_delta = amount + fee
elif transaction_type in {cls.DEBT, cls.EXPENDITURE}:
physical_delta = -amount
elif transaction_type == cls.CREDIT:
physical_delta = amount
else:
raise ValidationError('Unsupported transaction type.')
return cls._round(ecash_delta), cls._round(physical_delta), cls._round(fee)
@classmethod
@transaction.atomic
def create_logged_transaction(
cls,
*,
business: BusinessProfile,
user: User,
client_name: str,
amount: Decimal,
transaction_type: str,
notes: str = '',
):
ecash_delta, physical_delta, fee = cls.calculate_effect(transaction_type, amount)
ecash_before = cls._round(business.current_ecash)
physical_before = cls._round(business.current_physical_cash)
ecash_after = cls._round(ecash_before + ecash_delta)
physical_after = cls._round(physical_before + physical_delta)
if ecash_after < 0:
raise ValidationError('This transaction would make e-cash go below zero.')
if physical_after < 0:
raise ValidationError('This transaction would make physical cash go below zero.')
entry = cls.objects.create(
business=business,
created_by=user,
client_name=client_name,
amount=cls._round(amount),
transaction_type=transaction_type,
service_charge=fee,
notes=notes,
ecash_before=ecash_before,
ecash_after=ecash_after,
physical_before=physical_before,
physical_after=physical_after,
)
business.current_ecash = ecash_after
business.current_physical_cash = physical_after
business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at'])
return entry
@classmethod
def rebalance_business_ledger(cls, business: BusinessProfile):
business = BusinessProfile.objects.select_for_update().get(pk=business.pk)
ecash_balance = cls._round(business.opening_ecash)
physical_balance = cls._round(business.opening_physical_cash)
entries = list(
cls.objects.select_for_update()
.filter(business=business)
.order_by('created_at', 'id')
)
for entry in entries:
ecash_delta, physical_delta, fee = cls.calculate_effect(entry.transaction_type, entry.amount)
ecash_before = ecash_balance
physical_before = physical_balance
ecash_after = cls._round(ecash_before + ecash_delta)
physical_after = cls._round(physical_before + physical_delta)
cls.objects.filter(pk=entry.pk).update(
service_charge=fee,
ecash_before=ecash_before,
ecash_after=ecash_after,
physical_before=physical_before,
physical_after=physical_after,
)
ecash_balance = ecash_after
physical_balance = physical_after
business.current_ecash = ecash_balance
business.current_physical_cash = physical_balance
business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at'])
return business
@transaction.atomic
def delete_logged_transaction(self):
business = BusinessProfile.objects.select_for_update().get(pk=self.business_id)
transaction_id = self.pk
self.delete()
business = self.__class__.rebalance_business_ledger(business)
return {
'transaction_id': transaction_id,
'business': business,
}

View File

@ -1,25 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title>
{% if project_description %}
<meta name="description" content="{{ project_description }}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MoMoLedger{% endblock %}</title>
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description }}{% endblock %}">
{% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}">
{% endif %}
<meta name="author" content="MoMoLedger">
<meta name="keywords" content="momo, mobile money, cash-in, cash-out, wallet report">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
{% load static %}
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
</body>
<div class="page-shell">
<header class="site-header sticky-top">
<nav class="navbar navbar-expand-lg navbar-dark px-3 px-lg-4 py-3 momo-nav">
<div class="container-fluid px-0">
<a class="navbar-brand d-flex align-items-center gap-3" href="{% url 'home' %}">
<span class="brand-badge">M</span>
<span>
<span class="brand-name">MoMoLedger</span>
<small class="brand-subtitle d-block">Agent cashflow cockpit</small>
</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Home</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'transaction_list' %}">Transactions</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'reports' %}">Reports</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'profile' %}">Business setup</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
{% if request.user.is_authenticated %}
<li class="nav-item ms-lg-3"><a class="btn btn-momo-ghost" href="{% url 'logout' %}">Log out</a></li>
{% else %}
<li class="nav-item ms-lg-3"><a class="btn btn-momo-ghost" href="{% url 'login' %}">Log in</a></li>
<li class="nav-item"><a class="btn btn-momo-primary" href="{% url 'signup' %}">Sign up</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
</header>
<main>
<div class="container py-4 py-lg-5">
{% if messages %}
<div class="message-stack mb-4">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} shadow-sm border-0" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="auth-layout row g-4 align-items-stretch justify-content-center">
<div class="col-lg-5">
<article class="content-card h-100">
<p class="card-kicker mb-1">{% if mode == 'signup' %}Create your secure agent space{% else %}Welcome back{% endif %}</p>
<h1 class="section-title mb-3">{{ page_title }}</h1>
<p class="muted-copy mb-4">{% if mode == 'signup' %}Create a single-agent account, then add your business name, logo, opening balances, and first transactions.{% else %}Log in to continue recording cash movements, viewing balances, and exporting reports.{% endif %}</p>
<div class="stack-list compact">
<div class="stack-item"><strong>Polished dashboard</strong><span>Custom MoMo styling, not default Bootstrap.</span></div>
<div class="stack-item"><strong>Smart balance engine</strong><span>Each transaction updates e-cash and physical cash automatically.</span></div>
<div class="stack-item"><strong>Printable reports</strong><span>Export PDF and use browser print for sharing.</span></div>
</div>
</article>
</div>
<div class="col-lg-5">
<article class="content-card h-100">
<form method="post" class="momo-form" novalidate>
{% csrf_token %}
<div class="d-grid gap-3">
{% for field in form %}
<div>
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="form-help">{{ field.help_text }}</div>{% endif %}
{% for error in field.errors %}<div class="form-error">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
{% for error in form.non_field_errors %}<div class="form-error">{{ error }}</div>{% endfor %}
</div>
<button class="btn btn-momo-primary btn-lg w-100 mt-4" type="submit">{% if mode == 'signup' %}Create account{% else %}Log in{% endif %}</button>
</form>
<div class="auth-switch text-center mt-4">
{% if mode == 'signup' %}
<span>Already have an account?</span> <a class="text-link" href="{% url 'login' %}">Log in</a>
{% else %}
<span>Need an account?</span> <a class="text-link" href="{% url 'signup' %}">Sign up</a>
{% endif %}
</div>
</article>
</div>
</section>
{% endblock %}

View File

@ -1,145 +1,200 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ project_name }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<main>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<section class="hero-panel mb-5 overflow-hidden">
<div class="row align-items-center g-4 g-lg-5">
<div class="col-lg-7">
<span class="eyebrow">Mobile money bookkeeping made clear</span>
<h1 class="display-title">Run your MoMo business with branded reports, balanced wallets, and cleaner daily records.</h1>
<p class="hero-copy">Track e-cash, physical cash, cash-in, cash-out, sending, airtime, transfer, debt, expenditure, and credit in one polished workflow. MoMoLedger updates balances automatically and keeps your daily, weekly, monthly, and yearly reports ready to print or export as PDF.</p>
<div class="d-flex flex-wrap gap-3 mt-4">
{% if request.user.is_authenticated %}
<a class="btn btn-momo-primary btn-lg" href="{% url 'transaction_create' %}">Record a transaction</a>
<a class="btn btn-momo-ghost btn-lg" href="{% url 'reports' %}">Open reports</a>
{% else %}
<a class="btn btn-momo-primary btn-lg" href="{% url 'signup' %}">Create agent account</a>
<a class="btn btn-momo-ghost btn-lg" href="{% url 'login' %}">Sign in</a>
{% endif %}
</div>
<div class="hero-metrics row row-cols-1 row-cols-sm-3 g-3 mt-4">
<div class="col"><div class="metric-card"><strong>8</strong><span>Transaction types with smart balance rules</span></div></div>
<div class="col"><div class="metric-card"><strong>1%</strong><span>Sending fee calculated automatically</span></div></div>
<div class="col"><div class="metric-card"><strong>4</strong><span>Report ranges: day, week, month, year</span></div></div>
</div>
</div>
<div class="col-lg-5">
<div class="glass-card auth-showcase">
<div class="row g-3 g-lg-4">
<div class="col-md-6 col-lg-12 col-xl-6">
<div class="soft-card h-100">
<p class="card-kicker">Welcome page</p>
<h2 class="section-title-sm">Sign up & login</h2>
<p class="muted-copy">Start with a secure agent account, then return to your dashboard anytime.</p>
<div class="d-grid gap-2 mt-3">
<a class="btn btn-momo-primary" href="{% url 'signup' %}">Sign up</a>
<a class="btn btn-momo-ghost" href="{% url 'login' %}">Log in</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-12 col-xl-6">
<div class="soft-card h-100">
<p class="card-kicker">Brand your reports</p>
<h2 class="section-title-sm">Business setup</h2>
<p class="muted-copy">Add your business name, logo, and opening balances so every report looks official.</p>
{% if request.user.is_authenticated and profile %}
<div class="profile-mini mt-3">
<div>
<strong>{{ profile.business_name|default:'Business name not set' }}</strong>
<span>{{ profile.owner_label }}</span>
</div>
<a class="btn btn-sm btn-momo-ghost" href="{% url 'profile' %}">Edit</a>
</div>
{% else %}
<a class="btn btn-momo-ghost mt-3" href="{% url 'signup' %}">Create account to set profile</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div>
</main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}
<div class="shape shape-one"></div>
<div class="shape shape-two"></div>
</section>
{% if request.user.is_authenticated and profile %}
<section class="dashboard-grid mb-5">
<div class="row g-4">
<div class="col-lg-8">
<div class="content-card mb-4">
<div class="section-header d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<p class="card-kicker mb-1">Live balances</p>
<h2 class="section-title mb-0">{{ profile.business_name|default:'Your business dashboard' }}</h2>
</div>
<a class="btn btn-momo-primary" href="{% url 'transaction_create' %}">New transaction</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<article class="balance-card balance-card-primary">
<span>E-cash</span>
<strong>{{ profile.current_ecash }}</strong>
<small>Opening {{ profile.opening_ecash }}</small>
</article>
</div>
<div class="col-md-4">
<article class="balance-card balance-card-secondary">
<span>Physical cash</span>
<strong>{{ profile.current_physical_cash }}</strong>
<small>Opening {{ profile.opening_physical_cash }}</small>
</article>
</div>
<div class="col-md-4">
<article class="balance-card balance-card-accent">
<span>Todays volume</span>
<strong>{{ today_total }}</strong>
<small>{{ today_count }} transactions · fees {{ today_fees }}</small>
</article>
</div>
</div>
</div>
<div class="content-card">
<div class="section-header d-flex justify-content-between align-items-center mb-4">
<div>
<p class="card-kicker mb-1">Recent activity</p>
<h2 class="section-title mb-0">Latest transactions</h2>
</div>
<a class="text-link" href="{% url 'transaction_list' %}">View all</a>
</div>
{% if recent_transactions %}
<div class="table-responsive">
<table class="table align-middle momo-table">
<thead>
<tr>
<th>Client</th>
<th>Type</th>
<th>Amount</th>
<th>Fee</th>
<th>When</th>
<th></th>
</tr>
</thead>
<tbody>
{% for transaction in recent_transactions %}
<tr>
<td>{{ transaction.client_name }}</td>
<td><span class="badge momo-badge">{{ transaction.get_transaction_type_display }}</span></td>
<td>{{ transaction.amount }}</td>
<td>{{ transaction.service_charge }}</td>
<td>{{ transaction.created_at|date:"M d, Y H:i" }}</td>
<td><a class="text-link" href="{% url 'transaction_detail' transaction.id %}">Details</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<h3>No transactions yet</h3>
<p>Record your first cash movement to see balance updates and report activity.</p>
<a class="btn btn-momo-primary" href="{% url 'transaction_create' %}">Create first transaction</a>
</div>
{% endif %}
</div>
</div>
<div class="col-lg-4">
<div class="content-card mb-4">
<p class="card-kicker mb-1">Quick workflow</p>
<h2 class="section-title mb-3">What you can do now</h2>
<div class="stack-list">
<div class="stack-item">
<strong>1. Business setup</strong>
<span>Add name, logo, and opening balances.</span>
</div>
<div class="stack-item">
<strong>2. Log transactions</strong>
<span>Client name + amount + type updates wallets instantly.</span>
</div>
<div class="stack-item">
<strong>3. Export reports</strong>
<span>Daily, weekly, monthly, yearly, printable, and PDF-ready.</span>
</div>
</div>
</div>
<div class="content-card">
<p class="card-kicker mb-1">Report shortcuts</p>
<h2 class="section-title mb-3">Period summaries</h2>
<div class="report-shortcuts d-grid gap-2">
<a class="btn btn-momo-ghost text-start" href="{% url 'reports' %}?period=daily">Daily report</a>
<a class="btn btn-momo-ghost text-start" href="{% url 'reports' %}?period=weekly">Weekly report</a>
<a class="btn btn-momo-ghost text-start" href="{% url 'reports' %}?period=monthly">Monthly report</a>
<a class="btn btn-momo-ghost text-start" href="{% url 'reports' %}?period=yearly">Yearly report</a>
</div>
</div>
</div>
</div>
</section>
{% else %}
<section class="content-card">
<div class="row g-4 align-items-center">
<div class="col-lg-6">
<p class="card-kicker mb-1">First delivery included</p>
<h2 class="section-title">A real workflow, not just a landing page</h2>
<p class="muted-copy">This first version includes account access, business setup, transaction logging with balance rules, and a branded report center with print and PDF export.</p>
</div>
<div class="col-lg-6">
<div class="row g-3">
<div class="col-sm-6"><div class="mini-feature"><strong>Cash-in</strong><span>e-cash decreases, physical cash increases.</span></div></div>
<div class="col-sm-6"><div class="mini-feature"><strong>Cash-out</strong><span>physical cash decreases, e-cash increases.</span></div></div>
<div class="col-sm-6"><div class="mini-feature"><strong>Sending</strong><span>1% fee added automatically.</span></div></div>
<div class="col-sm-6"><div class="mini-feature"><strong>Reports</strong><span>Daily to yearly export flow.</span></div></div>
</div>
</div>
</div>
</section>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="page-intro mb-4">
<span class="eyebrow">Business setup</span>
<h1 class="section-title">Brand your MoMo reports</h1>
<p class="muted-copy">Set your business name, upload a logo, and define your opening wallet balances. These details appear across the dashboard and report exports.</p>
</section>
<div class="row g-4">
<div class="col-lg-7">
<article class="content-card">
<form method="post" enctype="multipart/form-data" class="momo-form" novalidate>
{% csrf_token %}
<div class="row g-3">
{% for field in form %}
<div class="col-12 {% if field.name != 'logo' %}col-md-6{% endif %}">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="form-help">{{ field.help_text }}</div>{% endif %}
{% for error in field.errors %}<div class="form-error">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
</div>
{% for error in form.non_field_errors %}<div class="form-error mt-3">{{ error }}</div>{% endfor %}
<button class="btn btn-momo-primary btn-lg mt-4" type="submit">Save business profile</button>
</form>
</article>
</div>
<div class="col-lg-5">
<article class="content-card h-100">
<p class="card-kicker mb-1">Report preview</p>
<h2 class="section-title mb-3">How your business appears</h2>
<div class="report-preview-card">
<div class="report-preview-header">
{% if profile.logo %}
<img src="{{ profile.logo.url }}" alt="{{ profile.business_name|default:'Business' }} logo" class="logo-thumb">
{% else %}
<div class="logo-placeholder">Logo</div>
{% endif %}
<div>
<strong>{{ profile.business_name|default:'Business name will appear here' }}</strong>
<span>{{ profile.owner_label }}</span>
<span>{{ request.user.email|default:'Email not set yet' }}</span>
</div>
</div>
<div class="preview-metrics">
<div><span>Opening e-cash</span><strong>{{ profile.opening_ecash }}</strong></div>
<div><span>Opening physical cash</span><strong>{{ profile.opening_physical_cash }}</strong></div>
<div><span>Current e-cash</span><strong>{{ profile.current_ecash }}</strong></div>
<div><span>Current physical cash</span><strong>{{ profile.current_physical_cash }}</strong></div>
</div>
</div>
<p class="form-help mt-3 mb-0">If you have not posted any transactions yet, saving this form also sets your starting current balances.</p>
</article>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="page-intro mb-4 d-flex flex-wrap justify-content-between align-items-end gap-3">
<div>
<span class="eyebrow">Report centre</span>
<h1 class="section-title">Daily, weekly, monthly, and yearly reports</h1>
<p class="muted-copy">Filter your period, print the page, or download a branded PDF that includes your business name, logo, and user details.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-momo-ghost" onclick="window.print()">Print report</button>
<a class="btn btn-momo-primary" href="{% url 'report_pdf' %}?period={{ form.cleaned_data.period|default:request.GET.period|default:'daily' }}{% if request.GET.start_date %}&start_date={{ request.GET.start_date }}{% endif %}{% if request.GET.end_date %}&end_date={{ request.GET.end_date }}{% endif %}">Download PDF</a>
</div>
</section>
<div class="row g-4 mb-4">
<div class="col-lg-4">
<article class="content-card h-100">
<form method="get" class="momo-form" novalidate>
<div class="d-grid gap-3">
{% for field in form %}
<div>
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% for error in field.errors %}<div class="form-error">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
{% for error in form.non_field_errors %}<div class="form-error">{{ error }}</div>{% endfor %}
</div>
<button class="btn btn-momo-primary mt-4" type="submit">Refresh report</button>
</form>
</article>
</div>
<div class="col-lg-8">
<article class="content-card report-sheet">
<div class="report-sheet-header">
<div class="report-brand d-flex gap-3 align-items-center">
{% if profile.logo %}
<img src="{{ profile.logo.url }}" alt="{{ profile.business_name }} logo" class="logo-thumb">
{% else %}
<div class="logo-placeholder">Logo</div>
{% endif %}
<div>
<h2 class="section-title mb-1">{{ profile.business_name|default:'MoMoLedger Business' }}</h2>
<p class="muted-copy mb-0">{{ profile.owner_label }} · {{ request.user.username }} · {{ request.user.email|default:'Email not set' }}</p>
</div>
</div>
<div class="report-window text-lg-end">
<span class="eyebrow">Report window</span>
<strong>{{ start_date }} to {{ end_date }}</strong>
</div>
</div>
<div class="row g-3 my-1">
<div class="col-md-3"><div class="mini-stat"><span>Transactions</span><strong>{{ total_count }}</strong></div></div>
<div class="col-md-3"><div class="mini-stat"><span>Gross amount</span><strong>{{ total_amount }}</strong></div></div>
<div class="col-md-3"><div class="mini-stat"><span>Service fees</span><strong>{{ total_fees }}</strong></div></div>
<div class="col-md-3"><div class="mini-stat"><span>Closing cash</span><strong>{{ closing_physical }}</strong></div></div>
</div>
<div class="preview-metrics two-up mb-4">
<div><span>Opening e-cash</span><strong>{{ profile.opening_ecash }}</strong></div>
<div><span>Opening physical cash</span><strong>{{ profile.opening_physical_cash }}</strong></div>
<div><span>Closing e-cash</span><strong>{{ closing_ecash }}</strong></div>
<div><span>Closing physical cash</span><strong>{{ closing_physical }}</strong></div>
</div>
<div class="table-responsive mb-4">
<table class="table align-middle momo-table">
<thead>
<tr>
<th>Type</th>
<th>Count</th>
<th>Amount</th>
<th>Fees</th>
</tr>
</thead>
<tbody>
{% for row in summary %}
<tr>
<td>{{ row.label }}</td>
<td>{{ row.count }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.fees }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="table-responsive">
<table class="table align-middle momo-table">
<thead>
<tr>
<th>Date</th>
<th>Client</th>
<th>Type</th>
<th>Amount</th>
<th>Fee</th>
<th>Balances after</th>
</tr>
</thead>
<tbody>
{% for transaction in entries %}
<tr>
<td>{{ transaction.created_at|date:"M d, Y H:i" }}</td>
<td>{{ transaction.client_name }}</td>
<td>{{ transaction.get_transaction_type_display }}</td>
<td>{{ transaction.amount }}</td>
<td>{{ transaction.service_charge }}</td>
<td>E {{ transaction.ecash_after }} · P {{ transaction.physical_after }}</td>
</tr>
{% empty %}
<tr>
<td colspan="6">
<div class="empty-state py-4">
<h3>No entries in this period</h3>
<p>Try another range or add a new transaction to populate the report.</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="page-intro mb-4 d-flex flex-wrap justify-content-between align-items-end gap-3">
<div>
<span class="eyebrow">Transaction detail</span>
<h1 class="section-title">{{ transaction.get_transaction_type_display }} for {{ transaction.client_name }}</h1>
<p class="muted-copy">Saved on {{ transaction.created_at|date:"F d, Y H:i" }}.</p>
</div>
<a class="btn btn-momo-ghost" href="{% url 'transaction_list' %}">Back to log</a>
</section>
<div class="row g-4">
<div class="col-lg-8">
<article class="content-card">
<div class="detail-grid">
<div><span>Business</span><strong>{{ profile.business_name }}</strong></div>
<div><span>Client</span><strong>{{ transaction.client_name }}</strong></div>
<div><span>Amount</span><strong>{{ transaction.amount }}</strong></div>
<div><span>Service charge</span><strong>{{ transaction.service_charge }}</strong></div>
<div><span>Recorded by</span><strong>{{ transaction.created_by.get_full_name|default:transaction.created_by.username }}</strong></div>
<div><span>Notes</span><strong>{{ transaction.notes|default:'—' }}</strong></div>
</div>
</article>
</div>
<div class="col-lg-4">
<article class="content-card h-100">
<p class="card-kicker mb-1">Balance impact</p>
<h2 class="section-title mb-3">Wallet movement</h2>
<div class="movement-card">
<div><span>E-cash before</span><strong>{{ transaction.ecash_before }}</strong></div>
<div><span>E-cash after</span><strong>{{ transaction.ecash_after }}</strong></div>
<div><span>Physical before</span><strong>{{ transaction.physical_before }}</strong></div>
<div><span>Physical after</span><strong>{{ transaction.physical_after }}</strong></div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="page-intro mb-4">
<span class="eyebrow">Transaction input</span>
<h1 class="section-title">Record a new cash movement</h1>
<p class="muted-copy">Choose a transaction type and MoMoLedger applies the wallet movement rules for you. Defaults: Debt and Expenditure reduce physical cash, while Credit increases physical cash.</p>
</section>
<div class="row g-4">
<div class="col-lg-7">
<article class="content-card">
<form method="post" class="momo-form" novalidate>
{% csrf_token %}
<div class="row g-3">
{% for field in form %}
<div class="col-12 {% if field.name != 'notes' %}col-md-6{% endif %}">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% for error in field.errors %}<div class="form-error">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
</div>
{% for error in form.non_field_errors %}<div class="form-error mt-3">{{ error }}</div>{% endfor %}
<button class="btn btn-momo-primary btn-lg mt-4" type="submit">Save transaction</button>
</form>
</article>
</div>
<div class="col-lg-5">
<article class="content-card h-100">
<p class="card-kicker mb-1">Current wallet state</p>
<h2 class="section-title mb-3">{{ profile.business_name }}</h2>
<div class="preview-metrics two-up">
<div><span>E-cash available</span><strong>{{ profile.current_ecash }}</strong></div>
<div><span>Physical cash available</span><strong>{{ profile.current_physical_cash }}</strong></div>
</div>
<div class="rule-list mt-4">
<div class="rule-item"><strong>Cash-In</strong><span>e-cash amount, physical cash + amount</span></div>
<div class="rule-item"><strong>Cash-out</strong><span>physical cash amount, e-cash + amount</span></div>
<div class="rule-item"><strong>Sending</strong><span>e-cash amount, physical cash + amount + 1% fee</span></div>
<div class="rule-item"><strong>Airtime / Transfer</strong><span>e-cash amount, physical cash + amount</span></div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="page-intro mb-4 d-flex flex-wrap justify-content-between align-items-end gap-3">
<div>
<span class="eyebrow">Transaction log</span>
<h1 class="section-title">All recorded transactions</h1>
<p class="muted-copy">Review entries, totals, and drill into the exact balance movements for each one.</p>
</div>
<a class="btn btn-momo-primary" href="{% url 'transaction_create' %}">Add transaction</a>
</section>
<section class="row g-4 mb-4">
<div class="col-md-4"><article class="balance-card balance-card-primary"><span>Total entries</span><strong>{{ summary.total_count|default:0 }}</strong></article></div>
<div class="col-md-4"><article class="balance-card balance-card-secondary"><span>Total amount</span><strong>{{ summary.total_amount|default:0 }}</strong></article></div>
<div class="col-md-4"><article class="balance-card balance-card-accent"><span>Total service fees</span><strong>{{ summary.total_fees|default:0 }}</strong></article></div>
</section>
<section class="content-card">
{% if transactions %}
<div class="table-responsive">
<table class="table align-middle momo-table">
<thead>
<tr>
<th>Client</th>
<th>Type</th>
<th>Amount</th>
<th>Service charge</th>
<th>Balances after</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{% for transaction in transactions %}
<tr>
<td>{{ transaction.client_name }}</td>
<td><span class="badge momo-badge">{{ transaction.get_transaction_type_display }}</span></td>
<td>{{ transaction.amount }}</td>
<td>{{ transaction.service_charge }}</td>
<td>E {{ transaction.ecash_after }} · P {{ transaction.physical_after }}</td>
<td>{{ transaction.created_at|date:"M d, Y H:i" }}</td>
<td><a class="text-link" href="{% url 'transaction_detail' transaction.id %}">Open</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<h3>No entries yet</h3>
<p>Your transaction log will appear here after the first saved record.</p>
<a class="btn btn-momo-primary" href="{% url 'transaction_create' %}">Create first transaction</a>
</div>
{% endif %}
</section>
{% endblock %}

View File

@ -1,3 +1,46 @@
from decimal import Decimal
from django.contrib.auth.models import User
from django.test import TestCase
# Create your tests here.
from .models import BusinessProfile, Transaction
class TransactionLogicTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='agent', password='secret123')
self.profile = BusinessProfile.objects.create(
user=self.user,
business_name='BrightPay',
opening_ecash=Decimal('1000.00'),
opening_physical_cash=Decimal('500.00'),
current_ecash=Decimal('1000.00'),
current_physical_cash=Decimal('500.00'),
)
def test_cash_in_moves_value_from_ecash_to_physical_cash(self):
entry = Transaction.create_logged_transaction(
business=self.profile,
user=self.user,
client_name='Ama',
amount=Decimal('100.00'),
transaction_type=Transaction.CASH_IN,
)
self.profile.refresh_from_db()
self.assertEqual(entry.ecash_after, Decimal('900.00'))
self.assertEqual(entry.physical_after, Decimal('600.00'))
self.assertEqual(self.profile.current_ecash, Decimal('900.00'))
self.assertEqual(self.profile.current_physical_cash, Decimal('600.00'))
def test_sending_adds_one_percent_service_charge_to_physical_cash(self):
entry = Transaction.create_logged_transaction(
business=self.profile,
user=self.user,
client_name='Kojo',
amount=Decimal('200.00'),
transaction_type=Transaction.SENDING,
)
self.profile.refresh_from_db()
self.assertEqual(entry.service_charge, Decimal('2.00'))
self.assertEqual(entry.ecash_after, Decimal('800.00'))
self.assertEqual(entry.physical_after, Decimal('702.00'))

View File

@ -1,7 +1,39 @@
from django.urls import path
from .views import home
from .views import (
api_health_view,
api_login_view,
api_logout_view,
api_profile_view,
api_transaction_detail_view,
api_transactions_view,
home,
login_view,
logout_view,
profile_view,
report_pdf_view,
reports_view,
signup_view,
transaction_create_view,
transaction_detail_view,
transaction_list_view,
)
urlpatterns = [
path("", home, name="home"),
path('', home, name='home'),
path('api/health/', api_health_view, name='api_health'),
path('api/login/', api_login_view, name='api_login'),
path('api/logout/', api_logout_view, name='api_logout'),
path('api/profile/', api_profile_view, name='api_profile'),
path('api/transactions/', api_transactions_view, name='api_transactions'),
path('api/transactions/<int:transaction_id>/', api_transaction_detail_view, name='api_transaction_detail'),
path('signup/', signup_view, name='signup'),
path('login/', login_view, name='login'),
path('logout/', logout_view, name='logout'),
path('profile/', profile_view, name='profile'),
path('transactions/new/', transaction_create_view, name='transaction_create'),
path('transactions/', transaction_list_view, name='transaction_list'),
path('transactions/<int:transaction_id>/', transaction_detail_view, name='transaction_detail'),
path('reports/', reports_view, name='reports'),
path('reports/pdf/', report_pdf_view, name='report_pdf'),
]

View File

@ -1,25 +1,543 @@
import os
import platform
import json
from datetime import datetime, time
from io import BytesIO
from django import get_version as django_version
from django.shortcuts import render
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 .models import BusinessProfile, Transaction
def api_login_required(view_func):
def wrapped(request, *args, **kwargs):
if not request.user.is_authenticated:
return JsonResponse({
'ok': False,
'error': 'Authentication 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):
"""Render the landing screen with loader and environment details."""
host_name = request.get_host().lower()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now()
context = {
"project_name": "New Style",
"agent_brand": agent_brand,
"django_version": django_version(),
"python_version": platform.python_version(),
"current_time": now,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
'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),
}
return render(request, "core/index.html", context)
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': request.user.is_authenticated,
})
@csrf_exempt
@require_POST
def api_login_view(request):
if request.user.is_authenticated:
profile = get_profile(request.user)
return JsonResponse({
'ok': True,
'message': 'Already logged in.',
'user': {
'username': request.user.username,
'email': request.user.email,
'business_name': profile.business_name,
},
})
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)
profile = get_profile(user)
return JsonResponse({
'ok': True,
'message': 'Login successful.',
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'business_name': profile.business_name,
},
})
@csrf_exempt
@require_POST
def api_logout_view(request):
if not request.user.is_authenticated:
return JsonResponse({
'ok': True,
'message': 'No active session.',
})
logout(request)
return JsonResponse({
'ok': True,
'message': 'Logout successful.',
})
@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(['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)
deleted_transaction = serialize_transaction(entry)
delete_result = entry.delete_logged_transaction()
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),
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -1,3 +1,4 @@
Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
reportlab==4.4.1

View File

@ -1,4 +1,539 @@
/* Custom styles for the application */
body {
font-family: system-ui, -apple-system, sans-serif;
:root {
--momo-primary: #0f6f5c;
--momo-primary-dark: #0a5748;
--momo-secondary: #143642;
--momo-accent: #f59e0b;
--momo-coral: #ef6f6c;
--momo-bg: #f4f6f0;
--momo-surface: #ffffff;
--momo-surface-alt: #eef4ef;
--momo-border: #d4dfd6;
--momo-text: #17332f;
--momo-muted: #647874;
--momo-shadow: 0 24px 60px rgba(16, 58, 50, 0.12);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', system-ui, sans-serif;
background:
radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 30%),
radial-gradient(circle at right top, rgba(15, 111, 92, 0.14), transparent 28%),
linear-gradient(180deg, #f7fbf8 0%, var(--momo-bg) 55%, #eef4ef 100%);
color: var(--momo-text);
min-height: 100vh;
}
h1, h2, h3, h4, .navbar-brand, .display-title, .section-title, .section-title-sm {
font-family: 'Space Grotesk', 'Inter', sans-serif;
letter-spacing: -0.03em;
}
a {
text-decoration: none;
}
.page-shell {
position: relative;
min-height: 100vh;
}
.site-header {
z-index: 100;
}
.momo-nav {
background: rgba(20, 54, 66, 0.82);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-badge {
width: 2.75rem;
height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.95rem;
background: linear-gradient(135deg, var(--momo-accent), #ffd279);
color: var(--momo-secondary);
font-weight: 800;
box-shadow: 0 16px 30px rgba(245, 158, 11, 0.25);
}
.brand-name {
font-size: 1.12rem;
font-weight: 700;
color: #fff;
}
.brand-subtitle {
color: rgba(255, 255, 255, 0.68);
font-size: 0.78rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.nav-link {
color: rgba(255, 255, 255, 0.78) !important;
font-weight: 500;
}
.nav-link:hover,
.nav-link:focus {
color: #fff !important;
}
.btn {
border-radius: 999px;
font-weight: 600;
padding: 0.8rem 1.4rem;
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.btn:hover,
.btn:focus {
transform: translateY(-1px);
}
.btn-momo-primary {
background: linear-gradient(135deg, var(--momo-primary), #16a085);
color: #fff;
border: none;
box-shadow: 0 16px 36px rgba(15, 111, 92, 0.24);
}
.btn-momo-primary:hover,
.btn-momo-primary:focus {
background: linear-gradient(135deg, #11806a, #18b18f);
color: #fff;
}
.btn-momo-ghost {
background: rgba(255, 255, 255, 0.12);
color: var(--momo-secondary);
border: 1px solid rgba(20, 54, 66, 0.12);
backdrop-filter: blur(12px);
}
.momo-nav .btn-momo-ghost {
color: #fff;
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
.hero-panel,
.content-card,
.glass-card,
.soft-card,
.balance-card,
.mini-feature,
.metric-card,
.mini-stat {
border: 1px solid rgba(255, 255, 255, 0.54);
background: rgba(255, 255, 255, 0.86);
box-shadow: var(--momo-shadow);
}
.hero-panel {
position: relative;
padding: 4.25rem clamp(1.4rem, 3vw, 3rem);
border-radius: calc(var(--radius-xl) + 8px);
background:
linear-gradient(135deg, rgba(20, 54, 66, 0.95), rgba(15, 111, 92, 0.84)),
linear-gradient(120deg, rgba(245, 158, 11, 0.2), transparent 42%);
color: #fff;
}
.display-title {
font-size: clamp(2.6rem, 4vw, 4.5rem);
line-height: 1.02;
margin-bottom: 1.2rem;
max-width: 12ch;
}
.hero-copy,
.muted-copy {
color: rgba(255, 255, 255, 0.82);
font-size: 1.05rem;
line-height: 1.72;
}
.eyebrow,
.card-kicker {
display: inline-block;
margin-bottom: 0.85rem;
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--momo-accent);
font-weight: 700;
}
.hero-metrics .metric-card,
.mini-feature,
.soft-card,
.mini-stat {
border-radius: var(--radius-md);
padding: 1.2rem;
}
.metric-card strong {
display: block;
font-size: 1.7rem;
color: var(--momo-secondary);
margin-bottom: 0.3rem;
}
.metric-card span,
.mini-feature span,
.stack-item span,
.preview-metrics span,
.movement-card span,
.detail-grid span,
.rule-item span,
.report-preview-header span,
.mini-stat span {
display: block;
color: var(--momo-muted);
font-size: 0.92rem;
line-height: 1.55;
}
.metric-card span {
color: #3e514d;
}
.glass-card {
padding: 1.25rem;
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(14px);
}
.soft-card {
background: rgba(255, 255, 255, 0.92);
height: 100%;
}
.section-title {
font-size: clamp(1.8rem, 2vw, 2.6rem);
color: var(--momo-secondary);
margin-bottom: 0.75rem;
}
.section-title-sm {
font-size: 1.32rem;
color: var(--momo-secondary);
margin-bottom: 0.55rem;
}
.content-card {
border-radius: var(--radius-xl);
padding: clamp(1.35rem, 3vw, 2rem);
}
.balance-card {
border-radius: var(--radius-lg);
padding: 1.35rem;
min-height: 100%;
}
.balance-card span,
.mini-stat span {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
font-weight: 700;
color: var(--momo-muted);
}
.balance-card strong,
.mini-stat strong {
display: block;
font-size: 1.9rem;
margin: 0.45rem 0;
color: var(--momo-secondary);
}
.balance-card small {
color: var(--momo-muted);
font-size: 0.88rem;
}
.balance-card-primary {
background: linear-gradient(135deg, rgba(15, 111, 92, 0.14), rgba(255, 255, 255, 0.96));
}
.balance-card-secondary {
background: linear-gradient(135deg, rgba(20, 54, 66, 0.12), rgba(255, 255, 255, 0.96));
}
.balance-card-accent {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.18), rgba(255, 255, 255, 0.96));
}
.shape {
position: absolute;
border-radius: 999px;
opacity: 0.22;
pointer-events: none;
}
.shape-one {
width: 180px;
height: 180px;
right: -36px;
top: -28px;
background: linear-gradient(135deg, var(--momo-accent), #ffe1aa);
}
.shape-two {
width: 260px;
height: 260px;
right: 14%;
bottom: -120px;
background: linear-gradient(135deg, rgba(255,255,255,0.45), rgba(255,255,255,0.08));
}
.empty-state {
text-align: center;
padding: 2.6rem 1.2rem;
border: 1px dashed var(--momo-border);
border-radius: var(--radius-lg);
background: var(--momo-surface-alt);
}
.momo-table {
--bs-table-bg: transparent;
--bs-table-border-color: rgba(20, 54, 66, 0.08);
}
.momo-table thead th {
color: var(--momo-muted);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom-width: 1px;
}
.momo-badge {
background: rgba(15, 111, 92, 0.12);
color: var(--momo-primary-dark);
border-radius: 999px;
padding: 0.55rem 0.75rem;
font-weight: 600;
}
.text-link {
color: var(--momo-primary);
font-weight: 700;
}
.stack-list {
display: grid;
gap: 0.9rem;
}
.stack-item,
.rule-item {
padding: 1rem 1.05rem;
border-radius: 1rem;
background: var(--momo-surface-alt);
border: 1px solid var(--momo-border);
}
.stack-item strong,
.rule-item strong {
display: block;
margin-bottom: 0.25rem;
color: var(--momo-secondary);
}
.momo-form .form-label {
font-weight: 700;
color: var(--momo-secondary);
margin-bottom: 0.45rem;
}
.momo-input,
.momo-file {
border-radius: 1rem;
border: 1px solid rgba(20, 54, 66, 0.14);
padding: 0.92rem 1rem;
background: #fff;
box-shadow: none !important;
}
.momo-input:focus,
.momo-file:focus,
.btn:focus,
.nav-link:focus {
border-color: rgba(15, 111, 92, 0.5);
box-shadow: 0 0 0 0.25rem rgba(15, 111, 92, 0.12) !important;
outline: none;
}
.form-help {
margin-top: 0.35rem;
color: var(--momo-muted);
font-size: 0.87rem;
}
.form-error {
margin-top: 0.4rem;
color: #b4373b;
font-size: 0.9rem;
font-weight: 600;
}
.profile-mini,
.report-preview-header,
.report-sheet-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.profile-mini span,
.report-preview-header span {
font-size: 0.86rem;
}
.logo-thumb,
.logo-placeholder {
width: 72px;
height: 72px;
border-radius: 1.25rem;
object-fit: cover;
border: 1px solid rgba(20, 54, 66, 0.1);
background: linear-gradient(135deg, rgba(15,111,92,0.14), rgba(245,158,11,0.16));
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--momo-secondary);
font-weight: 700;
}
.preview-metrics,
.detail-grid,
.movement-card {
display: grid;
gap: 0.9rem;
}
.preview-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.preview-metrics.two-up,
.detail-grid,
.movement-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.preview-metrics div,
.detail-grid div,
.movement-card div,
.report-preview-card,
.mini-stat {
padding: 1rem;
border-radius: 1rem;
background: var(--momo-surface-alt);
border: 1px solid var(--momo-border);
}
.preview-metrics strong,
.detail-grid strong,
.movement-card strong {
display: block;
color: var(--momo-secondary);
font-size: 1.08rem;
margin-top: 0.35rem;
}
.report-sheet {
border: 1px solid rgba(20, 54, 66, 0.08);
}
.mini-stat {
height: 100%;
}
.page-intro .muted-copy,
.content-card .muted-copy,
.auth-layout .muted-copy {
color: var(--momo-muted);
}
.auth-layout {
min-height: 66vh;
}
.compact .stack-item {
padding: 0.85rem 0.95rem;
}
@media print {
.site-header,
.btn,
.message-stack,
.page-intro,
.report-shortcuts {
display: none !important;
}
body {
background: #fff;
}
.container,
.content-card,
.report-sheet {
max-width: none !important;
box-shadow: none !important;
border: none !important;
padding: 0 !important;
}
}
@media (max-width: 991.98px) {
.display-title {
max-width: 100%;
}
.profile-mini,
.report-preview-header,
.report-sheet-header,
.preview-metrics,
.preview-metrics.two-up,
.detail-grid,
.movement-card {
grid-template-columns: 1fr;
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 767.98px) {
.hero-panel {
padding: 2rem 1.2rem 2.3rem;
}
.display-title {
font-size: 2.35rem;
}
.content-card,
.balance-card {
padding: 1.25rem;
}
}

View File

@ -1,21 +1,539 @@
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
--momo-primary: #0f6f5c;
--momo-primary-dark: #0a5748;
--momo-secondary: #143642;
--momo-accent: #f59e0b;
--momo-coral: #ef6f6c;
--momo-bg: #f4f6f0;
--momo-surface: #ffffff;
--momo-surface-alt: #eef4ef;
--momo-border: #d4dfd6;
--momo-text: #17332f;
--momo-muted: #647874;
--momo-shadow: 0 24px 60px rgba(16, 58, 50, 0.12);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
font-family: 'Inter', system-ui, sans-serif;
background:
radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 30%),
radial-gradient(circle at right top, rgba(15, 111, 92, 0.14), transparent 28%),
linear-gradient(180deg, #f7fbf8 0%, var(--momo-bg) 55%, #eef4ef 100%);
color: var(--momo-text);
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
h1, h2, h3, h4, .navbar-brand, .display-title, .section-title, .section-title-sm {
font-family: 'Space Grotesk', 'Inter', sans-serif;
letter-spacing: -0.03em;
}
a {
text-decoration: none;
}
.page-shell {
position: relative;
min-height: 100vh;
}
.site-header {
z-index: 100;
}
.momo-nav {
background: rgba(20, 54, 66, 0.82);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-badge {
width: 2.75rem;
height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.95rem;
background: linear-gradient(135deg, var(--momo-accent), #ffd279);
color: var(--momo-secondary);
font-weight: 800;
box-shadow: 0 16px 30px rgba(245, 158, 11, 0.25);
}
.brand-name {
font-size: 1.12rem;
font-weight: 700;
color: #fff;
}
.brand-subtitle {
color: rgba(255, 255, 255, 0.68);
font-size: 0.78rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.nav-link {
color: rgba(255, 255, 255, 0.78) !important;
font-weight: 500;
}
.nav-link:hover,
.nav-link:focus {
color: #fff !important;
}
.btn {
border-radius: 999px;
font-weight: 600;
padding: 0.8rem 1.4rem;
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.btn:hover,
.btn:focus {
transform: translateY(-1px);
}
.btn-momo-primary {
background: linear-gradient(135deg, var(--momo-primary), #16a085);
color: #fff;
border: none;
box-shadow: 0 16px 36px rgba(15, 111, 92, 0.24);
}
.btn-momo-primary:hover,
.btn-momo-primary:focus {
background: linear-gradient(135deg, #11806a, #18b18f);
color: #fff;
}
.btn-momo-ghost {
background: rgba(255, 255, 255, 0.12);
color: var(--momo-secondary);
border: 1px solid rgba(20, 54, 66, 0.12);
backdrop-filter: blur(12px);
}
.momo-nav .btn-momo-ghost {
color: #fff;
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
.hero-panel,
.content-card,
.glass-card,
.soft-card,
.balance-card,
.mini-feature,
.metric-card,
.mini-stat {
border: 1px solid rgba(255, 255, 255, 0.54);
background: rgba(255, 255, 255, 0.86);
box-shadow: var(--momo-shadow);
}
.hero-panel {
position: relative;
padding: 4.25rem clamp(1.4rem, 3vw, 3rem);
border-radius: calc(var(--radius-xl) + 8px);
background:
linear-gradient(135deg, rgba(20, 54, 66, 0.95), rgba(15, 111, 92, 0.84)),
linear-gradient(120deg, rgba(245, 158, 11, 0.2), transparent 42%);
color: #fff;
}
.display-title {
font-size: clamp(2.6rem, 4vw, 4.5rem);
line-height: 1.02;
margin-bottom: 1.2rem;
max-width: 12ch;
}
.hero-copy,
.muted-copy {
color: rgba(255, 255, 255, 0.82);
font-size: 1.05rem;
line-height: 1.72;
}
.eyebrow,
.card-kicker {
display: inline-block;
margin-bottom: 0.85rem;
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--momo-accent);
font-weight: 700;
}
.hero-metrics .metric-card,
.mini-feature,
.soft-card,
.mini-stat {
border-radius: var(--radius-md);
padding: 1.2rem;
}
.metric-card strong {
display: block;
font-size: 1.7rem;
color: var(--momo-secondary);
margin-bottom: 0.3rem;
}
.metric-card span,
.mini-feature span,
.stack-item span,
.preview-metrics span,
.movement-card span,
.detail-grid span,
.rule-item span,
.report-preview-header span,
.mini-stat span {
display: block;
color: var(--momo-muted);
font-size: 0.92rem;
line-height: 1.55;
}
.metric-card span {
color: #3e514d;
}
.glass-card {
padding: 1.25rem;
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(14px);
}
.soft-card {
background: rgba(255, 255, 255, 0.92);
height: 100%;
}
.section-title {
font-size: clamp(1.8rem, 2vw, 2.6rem);
color: var(--momo-secondary);
margin-bottom: 0.75rem;
}
.section-title-sm {
font-size: 1.32rem;
color: var(--momo-secondary);
margin-bottom: 0.55rem;
}
.content-card {
border-radius: var(--radius-xl);
padding: clamp(1.35rem, 3vw, 2rem);
}
.balance-card {
border-radius: var(--radius-lg);
padding: 1.35rem;
min-height: 100%;
}
.balance-card span,
.mini-stat span {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
font-weight: 700;
color: var(--momo-muted);
}
.balance-card strong,
.mini-stat strong {
display: block;
font-size: 1.9rem;
margin: 0.45rem 0;
color: var(--momo-secondary);
}
.balance-card small {
color: var(--momo-muted);
font-size: 0.88rem;
}
.balance-card-primary {
background: linear-gradient(135deg, rgba(15, 111, 92, 0.14), rgba(255, 255, 255, 0.96));
}
.balance-card-secondary {
background: linear-gradient(135deg, rgba(20, 54, 66, 0.12), rgba(255, 255, 255, 0.96));
}
.balance-card-accent {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.18), rgba(255, 255, 255, 0.96));
}
.shape {
position: absolute;
border-radius: 999px;
opacity: 0.22;
pointer-events: none;
}
.shape-one {
width: 180px;
height: 180px;
right: -36px;
top: -28px;
background: linear-gradient(135deg, var(--momo-accent), #ffe1aa);
}
.shape-two {
width: 260px;
height: 260px;
right: 14%;
bottom: -120px;
background: linear-gradient(135deg, rgba(255,255,255,0.45), rgba(255,255,255,0.08));
}
.empty-state {
text-align: center;
padding: 2.6rem 1.2rem;
border: 1px dashed var(--momo-border);
border-radius: var(--radius-lg);
background: var(--momo-surface-alt);
}
.momo-table {
--bs-table-bg: transparent;
--bs-table-border-color: rgba(20, 54, 66, 0.08);
}
.momo-table thead th {
color: var(--momo-muted);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom-width: 1px;
}
.momo-badge {
background: rgba(15, 111, 92, 0.12);
color: var(--momo-primary-dark);
border-radius: 999px;
padding: 0.55rem 0.75rem;
font-weight: 600;
}
.text-link {
color: var(--momo-primary);
font-weight: 700;
}
.stack-list {
display: grid;
gap: 0.9rem;
}
.stack-item,
.rule-item {
padding: 1rem 1.05rem;
border-radius: 1rem;
background: var(--momo-surface-alt);
border: 1px solid var(--momo-border);
}
.stack-item strong,
.rule-item strong {
display: block;
margin-bottom: 0.25rem;
color: var(--momo-secondary);
}
.momo-form .form-label {
font-weight: 700;
color: var(--momo-secondary);
margin-bottom: 0.45rem;
}
.momo-input,
.momo-file {
border-radius: 1rem;
border: 1px solid rgba(20, 54, 66, 0.14);
padding: 0.92rem 1rem;
background: #fff;
box-shadow: none !important;
}
.momo-input:focus,
.momo-file:focus,
.btn:focus,
.nav-link:focus {
border-color: rgba(15, 111, 92, 0.5);
box-shadow: 0 0 0 0.25rem rgba(15, 111, 92, 0.12) !important;
outline: none;
}
.form-help {
margin-top: 0.35rem;
color: var(--momo-muted);
font-size: 0.87rem;
}
.form-error {
margin-top: 0.4rem;
color: #b4373b;
font-size: 0.9rem;
font-weight: 600;
}
.profile-mini,
.report-preview-header,
.report-sheet-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.profile-mini span,
.report-preview-header span {
font-size: 0.86rem;
}
.logo-thumb,
.logo-placeholder {
width: 72px;
height: 72px;
border-radius: 1.25rem;
object-fit: cover;
border: 1px solid rgba(20, 54, 66, 0.1);
background: linear-gradient(135deg, rgba(15,111,92,0.14), rgba(245,158,11,0.16));
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--momo-secondary);
font-weight: 700;
}
.preview-metrics,
.detail-grid,
.movement-card {
display: grid;
gap: 0.9rem;
}
.preview-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.preview-metrics.two-up,
.detail-grid,
.movement-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.preview-metrics div,
.detail-grid div,
.movement-card div,
.report-preview-card,
.mini-stat {
padding: 1rem;
border-radius: 1rem;
background: var(--momo-surface-alt);
border: 1px solid var(--momo-border);
}
.preview-metrics strong,
.detail-grid strong,
.movement-card strong {
display: block;
color: var(--momo-secondary);
font-size: 1.08rem;
margin-top: 0.35rem;
}
.report-sheet {
border: 1px solid rgba(20, 54, 66, 0.08);
}
.mini-stat {
height: 100%;
}
.page-intro .muted-copy,
.content-card .muted-copy,
.auth-layout .muted-copy {
color: var(--momo-muted);
}
.auth-layout {
min-height: 66vh;
}
.compact .stack-item {
padding: 0.85rem 0.95rem;
}
@media print {
.site-header,
.btn,
.message-stack,
.page-intro,
.report-shortcuts {
display: none !important;
}
body {
background: #fff;
}
.container,
.content-card,
.report-sheet {
max-width: none !important;
box-shadow: none !important;
border: none !important;
padding: 0 !important;
}
}
@media (max-width: 991.98px) {
.display-title {
max-width: 100%;
}
.profile-mini,
.report-preview-header,
.report-sheet-header,
.preview-metrics,
.preview-metrics.two-up,
.detail-grid,
.movement-card {
grid-template-columns: 1fr;
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 767.98px) {
.hero-panel {
padding: 2rem 1.2rem 2.3rem;
}
.display-title {
font-size: 2.35rem;
}
.content-card,
.balance-card {
padding: 1.25rem;
}
}