Compare commits

...

2 Commits

Author SHA1 Message Date
Flatlogic Bot
0789752dc6 Auto commit: 2026-04-17T04:26:07.442Z 2026-04-17 04:26:07 +00:00
Flatlogic Bot
22e473eb14 Autosave: 20260417-025218 2026-04-17 02:52:16 +00:00
34 changed files with 3082 additions and 243 deletions

View File

@ -37,17 +37,11 @@ CSRF_TRUSTED_ORIGINS = [
for host in 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 SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None" SESSION_COOKIE_SAMESITE = "None"
CSRF_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 = [ INSTALLED_APPS = [
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
@ -65,8 +59,6 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
X_FRAME_OPTIONS = 'ALLOWALL' X_FRAME_OPTIONS = 'ALLOWALL'
@ -83,7 +75,6 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', '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', 'core.context_processors.project_context',
], ],
}, },
@ -92,10 +83,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'config.wsgi.application' WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.mysql', '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 = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', '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' LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'static', BASE_DIR / 'static',
BASE_DIR / 'assets', BASE_DIR / 'assets',
BASE_DIR / 'node_modules', BASE_DIR / 'node_modules',
] ]
# Email MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
EMAIL_BACKEND = os.getenv( EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND", "EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend" "django.core.mail.backends.smtp.EmailBackend"
@ -173,10 +145,16 @@ CONTACT_EMAIL_TO = [
if item.strip() if item.strip()
] ]
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL: if EMAIL_USE_SSL:
EMAIL_USE_TLS = False 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'
JWT_ACCESS_TOKEN_MINUTES = int(os.getenv('JWT_ACCESS_TOKEN_MINUTES', '30'))
JWT_REFRESH_TOKEN_DAYS = int(os.getenv('JWT_REFRESH_TOKEN_DAYS', '30'))
JWT_ISSUER = os.getenv('JWT_ISSUER', 'momoledger')
JWT_AUDIENCE = os.getenv('JWT_AUDIENCE', 'momoledger-mobile')
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 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 import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
@ -25,5 +9,6 @@ urlpatterns = [
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,17 @@
from django.contrib import admin 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'

165
core/forms.py Normal file
View File

@ -0,0 +1,165 @@
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, instance=None, **kwargs):
self.business = business
self.instance = instance
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
if self.instance is not 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.')
if self.instance is not None:
return self.instance.update_logged_transaction(
client_name=self.cleaned_data['client_name'],
amount=self.cleaned_data['amount'],
transaction_type=self.cleaned_data['transaction_type'],
notes=self.cleaned_data['notes'],
)
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']

112
core/jwt_auth.py Normal file
View File

@ -0,0 +1,112 @@
from datetime import timedelta
import jwt
from django.conf import settings
from django.contrib.auth.models import User
from django.utils import timezone
from .models import BusinessProfile
class JWTAuthError(Exception):
def __init__(self, message: str, *, code: str = 'token_invalid', status_code: int = 401):
super().__init__(message)
self.message = message
self.code = code
self.status_code = status_code
def _timestamp(value):
return int(value.timestamp())
def _base_payload(user: User, profile: BusinessProfile, *, token_type: str, expires_delta):
now = timezone.now()
return {
'sub': str(user.pk),
'username': user.username,
'type': token_type,
'token_version': profile.mobile_token_version,
'iat': _timestamp(now),
'nbf': _timestamp(now),
'exp': _timestamp(now + expires_delta),
'iss': settings.JWT_ISSUER,
'aud': settings.JWT_AUDIENCE,
}
def issue_token_pair(user: User):
profile, _ = BusinessProfile.objects.get_or_create(user=user)
access_lifetime = timedelta(minutes=settings.JWT_ACCESS_TOKEN_MINUTES)
refresh_lifetime = timedelta(days=settings.JWT_REFRESH_TOKEN_DAYS)
access_payload = _base_payload(user, profile, token_type='access', expires_delta=access_lifetime)
refresh_payload = _base_payload(user, profile, token_type='refresh', expires_delta=refresh_lifetime)
access_token = jwt.encode(access_payload, settings.SECRET_KEY, algorithm='HS256')
refresh_token = jwt.encode(refresh_payload, settings.SECRET_KEY, algorithm='HS256')
return {
'access': access_token,
'refresh': refresh_token,
'token_type': 'Bearer',
'access_expires_in': int(access_lifetime.total_seconds()),
'refresh_expires_in': int(refresh_lifetime.total_seconds()),
}
def decode_token(token: str, *, expected_type: str):
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=['HS256'],
audience=settings.JWT_AUDIENCE,
issuer=settings.JWT_ISSUER,
options={'require': ['exp', 'iat', 'nbf', 'sub', 'type', 'token_version']},
)
except jwt.ExpiredSignatureError as exc:
raise JWTAuthError('Token expired. Please log in again.', code='token_expired') from exc
except jwt.InvalidTokenError as exc:
raise JWTAuthError('Invalid token.', code='token_invalid') from exc
token_type = payload.get('type')
if token_type != expected_type:
raise JWTAuthError(f'Expected a {expected_type} token.', code='token_type_invalid')
return payload
def get_user_from_payload(payload):
user = User.objects.filter(pk=payload.get('sub'), is_active=True).first()
if user is None:
raise JWTAuthError('User account not found.', code='user_not_found')
profile, _ = BusinessProfile.objects.get_or_create(user=user)
if profile.mobile_token_version != payload.get('token_version'):
raise JWTAuthError('Token is no longer valid. Please log in again.', code='token_revoked')
return user, profile
def authenticate_authorization_header(header_value: str):
if not header_value:
raise JWTAuthError('Authentication required.', code='auth_required')
scheme, _, token = header_value.partition(' ')
if scheme.lower() != 'bearer' or not token.strip():
raise JWTAuthError('Use Authorization: Bearer <token>.', code='auth_header_invalid')
payload = decode_token(token.strip(), expected_type='access')
return get_user_from_payload(payload)
def authenticate_refresh_token(refresh_token: str):
if not refresh_token:
raise JWTAuthError('Refresh token is required.', code='refresh_required')
payload = decode_token(refresh_token.strip(), expected_type='refresh')
return get_user_from_payload(payload)
def revoke_user_tokens(user: User):
profile, _ = BusinessProfile.objects.get_or_create(user=user)
profile.mobile_token_version += 1
profile.save(update_fields=['mobile_token_version', 'updated_at'])
return profile

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

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-04-17 02:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='businessprofile',
name='mobile_token_version',
field=models.PositiveIntegerField(default=1),
),
]

View File

@ -1,3 +1,217 @@
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'))
mobile_token_version = models.PositiveIntegerField(default=1)
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)
if ecash_after < 0:
raise ValidationError('This change would make e-cash go below zero in your transaction history.')
if physical_after < 0:
raise ValidationError('This change would make physical cash go below zero in your transaction history.')
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 update_logged_transaction(self, *, client_name: str, amount: Decimal, transaction_type: str, notes: str = ''):
business = BusinessProfile.objects.select_for_update().get(pk=self.business_id)
self.client_name = client_name
self.amount = self.__class__._round(amount)
self.transaction_type = transaction_type
self.notes = notes
self.save(update_fields=['client_name', 'amount', 'transaction_type', 'notes'])
business = self.__class__.rebalance_business_ledger(business)
refreshed_entry = self.__class__.objects.select_related('created_by').get(pk=self.pk)
return {
'transaction': refreshed_entry,
'business': 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1">
{% if project_description %} <title>{% block title %}MoMoLedger{% endblock %}</title>
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description }}{% endblock %}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
{% if project_image_url %} {% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}"> <meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}"> <meta property="twitter:image" content="{{ project_image_url }}">
{% endif %} {% 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 %} {% load static %}
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
{% block content %}{% endblock %} <div class="page-shell">
</body> <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> </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" %} {% extends "base.html" %}
{% load static %}
{% block title %}{{ project_name }}{% endblock %} {% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% 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 content %} {% block content %}
<main> <section class="hero-panel mb-5 overflow-hidden">
<div class="card"> <div class="row align-items-center g-4 g-lg-5">
<h1>Analyzing your requirements and generating your app…</h1> <div class="col-lg-7">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <span class="eyebrow">Mobile money bookkeeping made clear</span>
<span class="sr-only">Loading…</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> </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> </div>
</main> <div class="shape shape-one"></div>
<footer> <div class="shape shape-two"></div>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) </section>
</footer>
{% 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 %} {% 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 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,42 @@
from django.urls import path 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_token_refresh_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 = [ 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/token/', api_login_view, name='api_token_login'),
path('api/token/refresh/', api_token_refresh_view, name='api_token_refresh'),
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,705 @@
import os import json
import platform from datetime import datetime, time
from functools import wraps
from io import BytesIO
from django import get_version as django_version from django.contrib import messages
from django.shortcuts import render 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 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): 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 = { context = {
"project_name": "New Style", 'page_title': 'MoMoLedger | Agent wallet dashboard',
"agent_brand": agent_brand, 'meta_description': 'Manage MoMo transactions, balances, business branding, and daily-to-yearly reports from one polished dashboard.',
"django_version": django_version(), 'signup_form': SignUpForm(),
"python_version": platform.python_version(), 'login_form': LoginForm(request=request),
"current_time": now,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
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': 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),
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

View File

@ -1,4 +1,539 @@
/* Custom styles for the application */ :root {
body { --momo-primary: #0f6f5c;
font-family: system-ui, -apple-system, sans-serif; --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 { :root {
--bg-color-start: #6a11cb; --momo-primary: #0f6f5c;
--bg-color-end: #2575fc; --momo-primary-dark: #0a5748;
--text-color: #ffffff; --momo-secondary: #143642;
--card-bg-color: rgba(255, 255, 255, 0.01); --momo-accent: #f59e0b;
--card-border-color: rgba(255, 255, 255, 0.1); --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 { body {
margin: 0; margin: 0;
font-family: 'Inter', sans-serif; font-family: 'Inter', system-ui, sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); background:
color: var(--text-color); radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 30%),
display: flex; radial-gradient(circle at right top, rgba(15, 111, 92, 0.14), transparent 28%),
justify-content: center; linear-gradient(180deg, #f7fbf8 0%, var(--momo-bg) 55%, #eef4ef 100%);
align-items: center; color: var(--momo-text);
min-height: 100vh; 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;
}
} }