diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a7..f942531 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 8cf22af..9c5f7f9 100644 Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index 291d043..c03dda4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -37,17 +37,11 @@ CSRF_TRUSTED_ORIGINS = [ for host in CSRF_TRUSTED_ORIGINS ] -# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy. SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SESSION_COOKIE_SAMESITE = "None" CSRF_COOKIE_SAMESITE = "None" -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ - -# Application definition - INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -65,8 +59,6 @@ MIDDLEWARE = [ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - # Disable X-Frame-Options middleware to allow Flatlogic preview iframes. - # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] X_FRAME_OPTIONS = 'ALLOWALL' @@ -83,7 +75,6 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - # IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp 'core.context_processors.project_context', ], }, @@ -92,10 +83,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'config.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', @@ -110,10 +97,6 @@ DATABASES = { }, } - -# Password validation -# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', @@ -129,33 +112,22 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - -# Internationalization -# https://docs.djangoproject.com/en/5.2/topics/i18n/ - LANGUAGE_CODE = 'en-us' - TIME_ZONE = 'UTC' - USE_I18N = True - USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.2/howto/static-files/ - STATIC_URL = 'static/' -# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS. STATIC_ROOT = BASE_DIR / 'staticfiles' - STATICFILES_DIRS = [ BASE_DIR / 'static', BASE_DIR / 'assets', BASE_DIR / 'node_modules', ] -# Email +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' + EMAIL_BACKEND = os.getenv( "EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend" @@ -173,10 +145,11 @@ CONTACT_EMAIL_TO = [ if item.strip() ] -# When both TLS and SSL flags are enabled, prefer SSL explicitly if EMAIL_USE_SSL: EMAIL_USE_TLS = False -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = 'home' +LOGOUT_REDIRECT_URL = 'home' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/config/urls.py b/config/urls.py index bcfc074..87f0aa2 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,23 +1,7 @@ -""" -URL configuration for config project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.2/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" -from django.contrib import admin -from django.urls import include, path from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), @@ -25,5 +9,6 @@ urlpatterns = [ ] if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a..aaa935c 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..9ff84cf Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5f..fb3db03 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..a57262b Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988..af00d75 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c..6e6731f 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8c38f3f..ede5b17 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,17 @@ from django.contrib import admin -# Register your models here. +from .models import BusinessProfile, Transaction + + +@admin.register(BusinessProfile) +class BusinessProfileAdmin(admin.ModelAdmin): + list_display = ('business_name', 'user', 'opening_ecash', 'opening_physical_cash', 'current_ecash', 'current_physical_cash') + search_fields = ('business_name', 'user__username', 'user__email') + + +@admin.register(Transaction) +class TransactionAdmin(admin.ModelAdmin): + list_display = ('client_name', 'transaction_type', 'amount', 'service_charge', 'business', 'created_at') + list_filter = ('transaction_type', 'created_at') + search_fields = ('client_name', 'business__business_name', 'created_by__username') + date_hierarchy = 'created_at' diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..f3cfd94 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,154 @@ +from decimal import Decimal + +from django import forms +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.utils import timezone + +from .models import BusinessProfile, Transaction + + +INPUT_CLASS = 'form-control form-control-lg momo-input' +SELECT_CLASS = 'form-select form-select-lg momo-input' + + +class SignUpForm(UserCreationForm): + first_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'First name'})) + last_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Last name'})) + email = forms.EmailField(required=True, widget=forms.EmailInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Email address'})) + + class Meta(UserCreationForm.Meta): + model = User + fields = ('first_name', 'last_name', 'username', 'email') + widgets = { + 'username': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['username'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Username'}) + self.fields['password1'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Password'}) + self.fields['password2'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Confirm password'}) + + def save(self, commit=True): + user = super().save(commit=False) + user.first_name = self.cleaned_data['first_name'] + user.last_name = self.cleaned_data['last_name'] + user.email = self.cleaned_data['email'] + if commit: + user.save() + return user + + +class LoginForm(AuthenticationForm): + username = forms.CharField(widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'})) + password = forms.CharField(widget=forms.PasswordInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Password'})) + + +class BusinessProfileForm(forms.ModelForm): + class Meta: + model = BusinessProfile + fields = ['business_name', 'logo', 'opening_ecash', 'opening_physical_cash'] + widgets = { + 'business_name': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'e.g. BrightPay MoMo Point'}), + 'logo': forms.ClearableFileInput(attrs={'class': 'form-control momo-file', 'accept': 'image/*'}), + 'opening_ecash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}), + 'opening_physical_cash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}), + } + help_texts = { + 'opening_ecash': 'Used as your starting e-cash wallet for the first setup.', + 'opening_physical_cash': 'Used as your starting notes/coins balance for the first setup.', + } + + def save(self, commit=True): + profile = super().save(commit=False) + should_sync = not profile.pk or not profile.transactions.exists() + if should_sync: + profile.sync_current_to_opening() + if commit: + profile.save() + return profile + + +class TransactionForm(forms.Form): + client_name = forms.CharField(max_length=120, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Client name'})) + amount = forms.DecimalField(max_digits=12, decimal_places=2, min_value=Decimal('0.01'), widget=forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0.01'})) + transaction_type = forms.ChoiceField(choices=Transaction.TYPE_CHOICES, widget=forms.Select(attrs={'class': SELECT_CLASS})) + notes = forms.CharField(required=False, widget=forms.Textarea(attrs={'class': 'form-control momo-input', 'rows': 3, 'placeholder': 'Optional note about the transaction'})) + + def __init__(self, *args, business=None, **kwargs): + self.business = business + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + transaction_type = cleaned_data.get('transaction_type') + amount = cleaned_data.get('amount') + if not self.business or not transaction_type or amount is None: + return cleaned_data + + ecash_delta, physical_delta, _ = Transaction.calculate_effect(transaction_type, amount) + if self.business.current_ecash + ecash_delta < 0: + self.add_error('amount', 'Not enough e-cash for this transaction.') + if self.business.current_physical_cash + physical_delta < 0: + self.add_error('amount', 'Not enough physical cash for this transaction.') + return cleaned_data + + def save(self, user): + if not self.business: + raise ValidationError('Business profile is required.') + return Transaction.create_logged_transaction( + business=self.business, + user=user, + client_name=self.cleaned_data['client_name'], + amount=self.cleaned_data['amount'], + transaction_type=self.cleaned_data['transaction_type'], + notes=self.cleaned_data['notes'], + ) + + +class ReportFilterForm(forms.Form): + PERIOD_CHOICES = [ + ('daily', 'Daily'), + ('weekly', 'Weekly'), + ('monthly', 'Monthly'), + ('yearly', 'Yearly'), + ('custom', 'Custom range'), + ] + + period = forms.ChoiceField(choices=PERIOD_CHOICES, initial='daily', widget=forms.Select(attrs={'class': 'form-select momo-input'})) + start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'})) + end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'})) + + def clean(self): + cleaned_data = super().clean() + period = cleaned_data.get('period') + start_date = cleaned_data.get('start_date') + end_date = cleaned_data.get('end_date') + if period == 'custom': + if not start_date or not end_date: + raise forms.ValidationError('Choose both a start and end date for a custom report.') + if end_date < start_date: + raise forms.ValidationError('End date cannot be before start date.') + return cleaned_data + + def get_range(self): + today = timezone.localdate() + period = self.cleaned_data.get('period') or 'daily' + if period == 'daily': + return today, today + if period == 'weekly': + start = today - timezone.timedelta(days=today.weekday()) + return start, start + timezone.timedelta(days=6) + if period == 'monthly': + start = today.replace(day=1) + if start.month == 12: + next_month = start.replace(year=start.year + 1, month=1, day=1) + else: + next_month = start.replace(month=start.month + 1, day=1) + return start, next_month - timezone.timedelta(days=1) + if period == 'yearly': + start = today.replace(month=1, day=1) + return start, today.replace(month=12, day=31) + return self.cleaned_data['start_date'], self.cleaned_data['end_date'] diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..ce3ccd4 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..1c82493 Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..e3297bd 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,196 @@ -from django.db import models +from decimal import Decimal, ROUND_HALF_UP -# Create your models here. +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db import models, transaction + + +class BusinessProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='business_profile') + business_name = models.CharField(max_length=120, blank=True) + logo = models.FileField(upload_to='business_logos/', blank=True, null=True) + opening_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + opening_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + current_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + current_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['user__username'] + + def __str__(self): + return self.business_name or f"{self.user.username}'s MoMo Business" + + @property + def owner_label(self): + return self.user.get_full_name() or self.user.username + + @property + def total_cash(self): + return (self.current_ecash or Decimal('0.00')) + (self.current_physical_cash or Decimal('0.00')) + + def sync_current_to_opening(self): + self.current_ecash = self.opening_ecash + self.current_physical_cash = self.opening_physical_cash + + +class Transaction(models.Model): + CASH_OUT = 'cash_out' + CASH_IN = 'cash_in' + SENDING = 'sending' + AIRTIME = 'airtime' + TRANSFER = 'transfer' + DEBT = 'debt' + EXPENDITURE = 'expenditure' + CREDIT = 'credit' + + TYPE_CHOICES = [ + (CASH_OUT, 'Cash-out'), + (CASH_IN, 'Cash-In'), + (SENDING, 'Sending'), + (AIRTIME, 'Airtime'), + (TRANSFER, 'Transfer'), + (DEBT, 'Debt'), + (EXPENDITURE, 'Expenditure'), + (CREDIT, 'Credit'), + ] + + business = models.ForeignKey(BusinessProfile, on_delete=models.CASCADE, related_name='transactions') + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='momo_transactions') + client_name = models.CharField(max_length=120) + amount = models.DecimalField(max_digits=12, decimal_places=2) + transaction_type = models.CharField(max_length=20, choices=TYPE_CHOICES) + service_charge = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00')) + notes = models.CharField(max_length=255, blank=True) + ecash_before = models.DecimalField(max_digits=12, decimal_places=2) + ecash_after = models.DecimalField(max_digits=12, decimal_places=2) + physical_before = models.DecimalField(max_digits=12, decimal_places=2) + physical_after = models.DecimalField(max_digits=12, decimal_places=2) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at', '-id'] + + def __str__(self): + return f"{self.get_transaction_type_display()} · {self.client_name} · {self.amount}" + + @staticmethod + def _round(value: Decimal) -> Decimal: + return value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + @classmethod + def calculate_effect(cls, transaction_type: str, amount: Decimal) -> tuple[Decimal, Decimal, Decimal]: + amount = cls._round(amount) + fee = Decimal('0.00') + ecash_delta = Decimal('0.00') + physical_delta = Decimal('0.00') + + if transaction_type == cls.CASH_IN: + ecash_delta = -amount + physical_delta = amount + elif transaction_type == cls.CASH_OUT: + ecash_delta = amount + physical_delta = -amount + elif transaction_type in {cls.AIRTIME, cls.TRANSFER}: + ecash_delta = -amount + physical_delta = amount + elif transaction_type == cls.SENDING: + fee = cls._round(amount * Decimal('0.01')) + ecash_delta = -amount + physical_delta = amount + fee + elif transaction_type in {cls.DEBT, cls.EXPENDITURE}: + physical_delta = -amount + elif transaction_type == cls.CREDIT: + physical_delta = amount + else: + raise ValidationError('Unsupported transaction type.') + + return cls._round(ecash_delta), cls._round(physical_delta), cls._round(fee) + + @classmethod + @transaction.atomic + def create_logged_transaction( + cls, + *, + business: BusinessProfile, + user: User, + client_name: str, + amount: Decimal, + transaction_type: str, + notes: str = '', + ): + ecash_delta, physical_delta, fee = cls.calculate_effect(transaction_type, amount) + ecash_before = cls._round(business.current_ecash) + physical_before = cls._round(business.current_physical_cash) + ecash_after = cls._round(ecash_before + ecash_delta) + physical_after = cls._round(physical_before + physical_delta) + + if ecash_after < 0: + raise ValidationError('This transaction would make e-cash go below zero.') + if physical_after < 0: + raise ValidationError('This transaction would make physical cash go below zero.') + + entry = cls.objects.create( + business=business, + created_by=user, + client_name=client_name, + amount=cls._round(amount), + transaction_type=transaction_type, + service_charge=fee, + notes=notes, + ecash_before=ecash_before, + ecash_after=ecash_after, + physical_before=physical_before, + physical_after=physical_after, + ) + + business.current_ecash = ecash_after + business.current_physical_cash = physical_after + business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at']) + return entry + + @classmethod + def rebalance_business_ledger(cls, business: BusinessProfile): + business = BusinessProfile.objects.select_for_update().get(pk=business.pk) + ecash_balance = cls._round(business.opening_ecash) + physical_balance = cls._round(business.opening_physical_cash) + + entries = list( + cls.objects.select_for_update() + .filter(business=business) + .order_by('created_at', 'id') + ) + for entry in entries: + ecash_delta, physical_delta, fee = cls.calculate_effect(entry.transaction_type, entry.amount) + ecash_before = ecash_balance + physical_before = physical_balance + ecash_after = cls._round(ecash_before + ecash_delta) + physical_after = cls._round(physical_before + physical_delta) + + cls.objects.filter(pk=entry.pk).update( + service_charge=fee, + ecash_before=ecash_before, + ecash_after=ecash_after, + physical_before=physical_before, + physical_after=physical_after, + ) + + ecash_balance = ecash_after + physical_balance = physical_after + + business.current_ecash = ecash_balance + business.current_physical_cash = physical_balance + business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at']) + return business + + @transaction.atomic + def delete_logged_transaction(self): + business = BusinessProfile.objects.select_for_update().get(pk=self.business_id) + transaction_id = self.pk + self.delete() + business = self.__class__.rebalance_business_ledger(business) + return { + 'transaction_id': transaction_id, + 'business': business, + } diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..1bae298 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,72 @@ - - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} + + {% block title %}MoMoLedger{% endblock %} + {% if project_image_url %} {% endif %} + + + + + + {% load static %} {% block head %}{% endblock %} - - {% block content %}{% endblock %} - +
+ +
+
+ {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} +
+
+
+ + {% block scripts %}{% endblock %} + diff --git a/core/templates/core/auth.html b/core/templates/core/auth.html new file mode 100644 index 0000000..d60f0ea --- /dev/null +++ b/core/templates/core/auth.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+
+

{% if mode == 'signup' %}Create your secure agent space{% else %}Welcome back{% endif %}

+

{{ page_title }}

+

{% 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 %}

+
+
Polished dashboardCustom MoMo styling, not default Bootstrap.
+
Smart balance engineEach transaction updates e-cash and physical cash automatically.
+
Printable reportsExport PDF and use browser print for sharing.
+
+
+
+
+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} + {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ +
+
+ {% if mode == 'signup' %} + Already have an account? Log in + {% else %} + Need an account? Sign up + {% endif %} +
+
+
+
+{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..da0664e 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,200 @@ {% extends "base.html" %} +{% load static %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+ Mobile money bookkeeping made clear +

Run your MoMo business with branded reports, balanced wallets, and cleaner daily records.

+

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.

+
+ {% if request.user.is_authenticated %} + Record a transaction + Open reports + {% else %} + Create agent account + Sign in + {% endif %} +
+
+
8Transaction types with smart balance rules
+
1%Sending fee calculated automatically
+
4Report ranges: day, week, month, year
+
+
+
+
+
+
+
+

Welcome page

+

Sign up & login

+

Start with a secure agent account, then return to your dashboard anytime.

+
+ Sign up + Log in +
+
+
+
+
+

Brand your reports

+

Business setup

+

Add your business name, logo, and opening balances so every report looks official.

+ {% if request.user.is_authenticated and profile %} +
+
+ {{ profile.business_name|default:'Business name not set' }} + {{ profile.owner_label }} +
+ Edit +
+ {% else %} + Create account to set profile + {% endif %} +
+
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
- -{% endblock %} \ No newline at end of file +
+
+ + +{% if request.user.is_authenticated and profile %} +
+
+
+
+
+
+

Live balances

+

{{ profile.business_name|default:'Your business dashboard' }}

+
+ New transaction +
+
+
+
+ E-cash + {{ profile.current_ecash }} + Opening {{ profile.opening_ecash }} +
+
+
+
+ Physical cash + {{ profile.current_physical_cash }} + Opening {{ profile.opening_physical_cash }} +
+
+
+
+ Today’s volume + {{ today_total }} + {{ today_count }} transactions · fees {{ today_fees }} +
+
+
+
+
+
+
+

Recent activity

+

Latest transactions

+
+ View all +
+ {% if recent_transactions %} +
+ + + + + + + + + + + + + {% for transaction in recent_transactions %} + + + + + + + + + {% endfor %} + +
ClientTypeAmountFeeWhen
{{ transaction.client_name }}{{ transaction.get_transaction_type_display }}{{ transaction.amount }}{{ transaction.service_charge }}{{ transaction.created_at|date:"M d, Y H:i" }}Details
+
+ {% else %} +
+

No transactions yet

+

Record your first cash movement to see balance updates and report activity.

+ Create first transaction +
+ {% endif %} +
+
+
+
+

Quick workflow

+

What you can do now

+
+
+ 1. Business setup + Add name, logo, and opening balances. +
+
+ 2. Log transactions + Client name + amount + type updates wallets instantly. +
+
+ 3. Export reports + Daily, weekly, monthly, yearly, printable, and PDF-ready. +
+
+
+
+

Report shortcuts

+

Period summaries

+ +
+
+
+
+{% else %} +
+
+
+

First delivery included

+

A real workflow, not just a landing page

+

This first version includes account access, business setup, transaction logging with balance rules, and a branded report center with print and PDF export.

+
+
+
+
Cash-ine-cash decreases, physical cash increases.
+
Cash-outphysical cash decreases, e-cash increases.
+
Sending1% fee added automatically.
+
ReportsDaily to yearly export flow.
+
+
+
+
+{% endif %} +{% endblock %} diff --git a/core/templates/core/profile_form.html b/core/templates/core/profile_form.html new file mode 100644 index 0000000..61c2463 --- /dev/null +++ b/core/templates/core/profile_form.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+ Business setup +

Brand your MoMo reports

+

Set your business name, upload a logo, and define your opening wallet balances. These details appear across the dashboard and report exports.

+
+
+
+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} + +
+
+
+
+
+

Report preview

+

How your business appears

+
+
+ {% if profile.logo %} + {{ profile.business_name|default:'Business' }} logo + {% else %} +
Logo
+ {% endif %} +
+ {{ profile.business_name|default:'Business name will appear here' }} + {{ profile.owner_label }} + {{ request.user.email|default:'Email not set yet' }} +
+
+
+
Opening e-cash{{ profile.opening_ecash }}
+
Opening physical cash{{ profile.opening_physical_cash }}
+
Current e-cash{{ profile.current_ecash }}
+
Current physical cash{{ profile.current_physical_cash }}
+
+
+

If you have not posted any transactions yet, saving this form also sets your starting current balances.

+
+
+
+{% endblock %} diff --git a/core/templates/core/reports.html b/core/templates/core/reports.html new file mode 100644 index 0000000..c899b32 --- /dev/null +++ b/core/templates/core/reports.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+ Report centre +

Daily, weekly, monthly, and yearly reports

+

Filter your period, print the page, or download a branded PDF that includes your business name, logo, and user details.

+
+
+ + Download PDF +
+
+
+
+
+
+
+ {% for field in form %} +
+ + {{ field }} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} + {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ +
+
+
+
+
+
+
+ {% if profile.logo %} + {{ profile.business_name }} logo + {% else %} +
Logo
+ {% endif %} +
+

{{ profile.business_name|default:'MoMoLedger Business' }}

+

{{ profile.owner_label }} · {{ request.user.username }} · {{ request.user.email|default:'Email not set' }}

+
+
+
+ Report window + {{ start_date }} to {{ end_date }} +
+
+
+
Transactions{{ total_count }}
+
Gross amount{{ total_amount }}
+
Service fees{{ total_fees }}
+
Closing cash{{ closing_physical }}
+
+
+
Opening e-cash{{ profile.opening_ecash }}
+
Opening physical cash{{ profile.opening_physical_cash }}
+
Closing e-cash{{ closing_ecash }}
+
Closing physical cash{{ closing_physical }}
+
+
+ + + + + + + + + + + {% for row in summary %} + + + + + + + {% endfor %} + +
TypeCountAmountFees
{{ row.label }}{{ row.count }}{{ row.amount }}{{ row.fees }}
+
+
+ + + + + + + + + + + + + {% for transaction in entries %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
DateClientTypeAmountFeeBalances after
{{ transaction.created_at|date:"M d, Y H:i" }}{{ transaction.client_name }}{{ transaction.get_transaction_type_display }}{{ transaction.amount }}{{ transaction.service_charge }}E {{ transaction.ecash_after }} · P {{ transaction.physical_after }}
+
+

No entries in this period

+

Try another range or add a new transaction to populate the report.

+
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/transaction_detail.html b/core/templates/core/transaction_detail.html new file mode 100644 index 0000000..307b74d --- /dev/null +++ b/core/templates/core/transaction_detail.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+ Transaction detail +

{{ transaction.get_transaction_type_display }} for {{ transaction.client_name }}

+

Saved on {{ transaction.created_at|date:"F d, Y H:i" }}.

+
+ Back to log +
+
+
+
+
+
Business{{ profile.business_name }}
+
Client{{ transaction.client_name }}
+
Amount{{ transaction.amount }}
+
Service charge{{ transaction.service_charge }}
+
Recorded by{{ transaction.created_by.get_full_name|default:transaction.created_by.username }}
+
Notes{{ transaction.notes|default:'—' }}
+
+
+
+
+
+

Balance impact

+

Wallet movement

+
+
E-cash before{{ transaction.ecash_before }}
+
E-cash after{{ transaction.ecash_after }}
+
Physical before{{ transaction.physical_before }}
+
Physical after{{ transaction.physical_after }}
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/transaction_form.html b/core/templates/core/transaction_form.html new file mode 100644 index 0000000..cc34a5c --- /dev/null +++ b/core/templates/core/transaction_form.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+ Transaction input +

Record a new cash movement

+

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.

+
+
+
+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ + {{ field }} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} + +
+
+
+
+
+

Current wallet state

+

{{ profile.business_name }}

+
+
E-cash available{{ profile.current_ecash }}
+
Physical cash available{{ profile.current_physical_cash }}
+
+
+
Cash-Ine-cash − amount, physical cash + amount
+
Cash-outphysical cash − amount, e-cash + amount
+
Sendinge-cash − amount, physical cash + amount + 1% fee
+
Airtime / Transfere-cash − amount, physical cash + amount
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/transaction_list.html b/core/templates/core/transaction_list.html new file mode 100644 index 0000000..3c23ac5 --- /dev/null +++ b/core/templates/core/transaction_list.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+ Transaction log +

All recorded transactions

+

Review entries, totals, and drill into the exact balance movements for each one.

+
+ Add transaction +
+
+
Total entries{{ summary.total_count|default:0 }}
+
Total amount{{ summary.total_amount|default:0 }}
+
Total service fees{{ summary.total_fees|default:0 }}
+
+
+ {% if transactions %} +
+ + + + + + + + + + + + + + {% for transaction in transactions %} + + + + + + + + + + {% endfor %} + +
ClientTypeAmountService chargeBalances afterDate
{{ transaction.client_name }}{{ transaction.get_transaction_type_display }}{{ transaction.amount }}{{ transaction.service_charge }}E {{ transaction.ecash_after }} · P {{ transaction.physical_after }}{{ transaction.created_at|date:"M d, Y H:i" }}Open
+
+ {% else %} +
+

No entries yet

+

Your transaction log will appear here after the first saved record.

+ Create first transaction +
+ {% endif %} +
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index 7ce503c..17b2c63 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,46 @@ +from decimal import Decimal + +from django.contrib.auth.models import User from django.test import TestCase -# Create your tests here. +from .models import BusinessProfile, Transaction + + +class TransactionLogicTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='agent', password='secret123') + self.profile = BusinessProfile.objects.create( + user=self.user, + business_name='BrightPay', + opening_ecash=Decimal('1000.00'), + opening_physical_cash=Decimal('500.00'), + current_ecash=Decimal('1000.00'), + current_physical_cash=Decimal('500.00'), + ) + + def test_cash_in_moves_value_from_ecash_to_physical_cash(self): + entry = Transaction.create_logged_transaction( + business=self.profile, + user=self.user, + client_name='Ama', + amount=Decimal('100.00'), + transaction_type=Transaction.CASH_IN, + ) + self.profile.refresh_from_db() + self.assertEqual(entry.ecash_after, Decimal('900.00')) + self.assertEqual(entry.physical_after, Decimal('600.00')) + self.assertEqual(self.profile.current_ecash, Decimal('900.00')) + self.assertEqual(self.profile.current_physical_cash, Decimal('600.00')) + + def test_sending_adds_one_percent_service_charge_to_physical_cash(self): + entry = Transaction.create_logged_transaction( + business=self.profile, + user=self.user, + client_name='Kojo', + amount=Decimal('200.00'), + transaction_type=Transaction.SENDING, + ) + self.profile.refresh_from_db() + self.assertEqual(entry.service_charge, Decimal('2.00')) + self.assertEqual(entry.ecash_after, Decimal('800.00')) + self.assertEqual(entry.physical_after, Decimal('702.00')) diff --git a/core/urls.py b/core/urls.py index 6299e3d..8a01ab5 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,39 @@ from django.urls import path -from .views import home +from .views import ( + api_health_view, + api_login_view, + api_logout_view, + api_profile_view, + api_transaction_detail_view, + api_transactions_view, + home, + login_view, + logout_view, + profile_view, + report_pdf_view, + reports_view, + signup_view, + transaction_create_view, + transaction_detail_view, + transaction_list_view, +) urlpatterns = [ - path("", home, name="home"), + path('', home, name='home'), + path('api/health/', api_health_view, name='api_health'), + path('api/login/', api_login_view, name='api_login'), + path('api/logout/', api_logout_view, name='api_logout'), + path('api/profile/', api_profile_view, name='api_profile'), + path('api/transactions/', api_transactions_view, name='api_transactions'), + path('api/transactions//', 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//', transaction_detail_view, name='transaction_detail'), + path('reports/', reports_view, name='reports'), + path('reports/pdf/', report_pdf_view, name='report_pdf'), ] diff --git a/core/views.py b/core/views.py index c9aed12..6c79b4c 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,543 @@ -import os -import platform +import json +from datetime import datetime, time +from io import BytesIO -from django import get_version as django_version -from django.shortcuts import render +from django.contrib import messages +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.db.models import Count, Sum +from django.http import FileResponse, Http404, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_POST, require_http_methods from django.utils import timezone +from .forms import BusinessProfileForm, LoginForm, ReportFilterForm, SignUpForm, TransactionForm +from .models import BusinessProfile, Transaction + + +def api_login_required(view_func): + def wrapped(request, *args, **kwargs): + if not request.user.is_authenticated: + return JsonResponse({ + 'ok': False, + 'error': 'Authentication required.', + }, status=401) + return view_func(request, *args, **kwargs) + + return wrapped + + +def serialize_transaction(entry): + return { + 'id': entry.id, + 'client_name': entry.client_name, + 'amount': str(entry.amount), + 'transaction_type': entry.transaction_type, + 'transaction_type_label': entry.get_transaction_type_display(), + 'service_charge': str(entry.service_charge), + 'notes': entry.notes, + 'ecash_before': str(entry.ecash_before), + 'ecash_after': str(entry.ecash_after), + 'physical_before': str(entry.physical_before), + 'physical_after': str(entry.physical_after), + 'created_by': entry.created_by.username, + 'created_at': timezone.localtime(entry.created_at).isoformat(), + } + + +def get_profile(user): + profile, _ = BusinessProfile.objects.get_or_create(user=user) + return profile + + +def build_report_snapshot(profile, params=None): + form = ReportFilterForm(params or None) + form.is_valid() + start_date, end_date = form.get_range() if form.cleaned_data else (timezone.localdate(), timezone.localdate()) + start_dt = timezone.make_aware(datetime.combine(start_date, time.min)) + end_dt = timezone.make_aware(datetime.combine(end_date, time.max)) + entries = profile.transactions.filter(created_at__range=(start_dt, end_dt)).select_related('created_by') + summary_rows = entries.values('transaction_type').annotate( + total_amount=Sum('amount'), + total_fees=Sum('service_charge'), + total_count=Count('id'), + ) + summary_map = {row['transaction_type']: row for row in summary_rows} + ordered_summary = [] + for value, label in Transaction.TYPE_CHOICES: + row = summary_map.get(value) + ordered_summary.append({ + 'key': value, + 'label': label, + 'count': row['total_count'] if row else 0, + 'amount': row['total_amount'] if row and row['total_amount'] else 0, + 'fees': row['total_fees'] if row and row['total_fees'] else 0, + }) + totals = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id')) + latest_entry = entries.order_by('-created_at', '-id').first() + closing_ecash = latest_entry.ecash_after if latest_entry else profile.current_ecash + closing_physical = latest_entry.physical_after if latest_entry else profile.current_physical_cash + return { + 'form': form, + 'entries': entries.order_by('-created_at', '-id'), + 'summary': ordered_summary, + 'total_amount': totals['total_amount'] or 0, + 'total_fees': totals['total_fees'] or 0, + 'total_count': totals['total_count'] or 0, + 'start_date': start_date, + 'end_date': end_date, + 'closing_ecash': closing_ecash, + 'closing_physical': closing_physical, + } + def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() - context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + 'page_title': 'MoMoLedger | Agent wallet dashboard', + 'meta_description': 'Manage MoMo transactions, balances, business branding, and daily-to-yearly reports from one polished dashboard.', + 'signup_form': SignUpForm(), + 'login_form': LoginForm(request=request), } - return render(request, "core/index.html", context) + if request.user.is_authenticated: + profile = get_profile(request.user) + report_snapshot = build_report_snapshot(profile, {'period': 'daily'}) + context.update({ + 'profile': profile, + 'recent_transactions': profile.transactions.select_related('created_by')[:5], + 'today_total': report_snapshot['total_amount'], + 'today_fees': report_snapshot['total_fees'], + 'today_count': report_snapshot['total_count'], + }) + return render(request, 'core/index.html', context) + + +def signup_view(request): + if request.user.is_authenticated: + return redirect('home') + form = SignUpForm(request.POST or None) + if request.method == 'POST' and form.is_valid(): + user = form.save() + BusinessProfile.objects.get_or_create(user=user) + login(request, user) + messages.success(request, 'Welcome! Your account is ready. Set your business details next.') + return redirect('profile') + return render(request, 'core/auth.html', { + 'form': form, + 'mode': 'signup', + 'page_title': 'Create your MoMo account', + 'meta_description': 'Create your mobile money business account and start tracking your balances securely.', + }) + + +def login_view(request): + if request.user.is_authenticated: + return redirect('home') + form = LoginForm(request=request, data=request.POST or None) + if request.method == 'POST' and form.is_valid(): + login(request, form.get_user()) + messages.success(request, 'Welcome back to your MoMo dashboard.') + return redirect('home') + return render(request, 'core/auth.html', { + 'form': form, + 'mode': 'login', + 'page_title': 'Log in to MoMoLedger', + 'meta_description': 'Access your mobile money dashboard, balances, transaction log, and reports.', + }) + + +@login_required +def logout_view(request): + logout(request) + messages.info(request, 'You have been logged out.') + return redirect('home') + + +@login_required +def profile_view(request): + profile = get_profile(request.user) + form = BusinessProfileForm(request.POST or None, request.FILES or None, instance=profile) + if request.method == 'POST' and form.is_valid(): + profile = form.save() + messages.success(request, 'Business profile saved. Your branding will now appear on reports.') + return redirect('profile') + return render(request, 'core/profile_form.html', { + 'form': form, + 'profile': profile, + 'page_title': 'Business setup | MoMoLedger', + 'meta_description': 'Update your business name, logo, and opening wallet balances for branded reports.', + }) + + +@login_required +def transaction_create_view(request): + profile = get_profile(request.user) + if not profile.business_name: + messages.info(request, 'Start by saving your business profile before logging transactions.') + return redirect('profile') + form = TransactionForm(request.POST or None, business=profile) + if request.method == 'POST' and form.is_valid(): + entry = form.save(request.user) + messages.success(request, 'Transaction recorded and balances updated automatically.') + return redirect('transaction_detail', transaction_id=entry.id) + return render(request, 'core/transaction_form.html', { + 'form': form, + 'profile': profile, + 'page_title': 'New transaction | MoMoLedger', + 'meta_description': 'Record cash-in, cash-out, sending, airtime, transfer, debt, expenditure, and credit in one place.', + }) + + +@login_required +def transaction_list_view(request): + profile = get_profile(request.user) + entries = profile.transactions.select_related('created_by') + summary = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id')) + return render(request, 'core/transaction_list.html', { + 'profile': profile, + 'transactions': entries, + 'summary': summary, + 'page_title': 'Transactions | MoMoLedger', + 'meta_description': 'Browse recent mobile money transactions and review balance movements.', + }) + + +@login_required +def transaction_detail_view(request, transaction_id): + profile = get_profile(request.user) + entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id) + return render(request, 'core/transaction_detail.html', { + 'profile': profile, + 'transaction': entry, + 'page_title': f'{entry.get_transaction_type_display()} details | MoMoLedger', + 'meta_description': 'Inspect one MoMo transaction and see the exact e-cash and physical cash balance effect.', + }) + + +@login_required +def reports_view(request): + profile = get_profile(request.user) + snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'}) + context = { + 'profile': profile, + 'page_title': 'Reports | MoMoLedger', + 'meta_description': 'View printable MoMo business reports by day, week, month, year, or a custom date range.', + **snapshot, + } + return render(request, 'core/reports.html', context) + + +@login_required +def report_pdf_view(request): + try: + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import cm + from reportlab.pdfbase.pdfmetrics import stringWidth + from reportlab.pdfgen import canvas + except Exception as exc: # pragma: no cover + raise Http404('PDF support is not available.') from exc + + profile = get_profile(request.user) + snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'}) + + buffer = BytesIO() + pdf = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 + margin = 1.5 * cm + y = height - margin + + pdf.setTitle('MoMo report') + pdf.setFillColor(colors.HexColor('#0f6f5c')) + pdf.rect(0, height - 4 * cm, width, 4 * cm, fill=1, stroke=0) + pdf.setFillColor(colors.white) + pdf.setFont('Helvetica-Bold', 18) + pdf.drawString(margin, height - 1.5 * cm, profile.business_name or 'MoMoLedger Business') + pdf.setFont('Helvetica', 10) + owner_text = f"Owner: {profile.owner_label} | User: {request.user.username} | Email: {request.user.email or 'Not set'}" + pdf.drawString(margin, height - 2.2 * cm, owner_text[:100]) + pdf.drawString(margin, height - 2.8 * cm, f"Report window: {snapshot['start_date']} to {snapshot['end_date']}") + + if profile.logo: + try: + pdf.drawImage(profile.logo.path, width - 4.5 * cm, height - 3.2 * cm, width=2.5 * cm, height=2.5 * cm, preserveAspectRatio=True, mask='auto') + except Exception: + pass + + y = height - 5.2 * cm + pdf.setFillColor(colors.HexColor('#143642')) + pdf.setFont('Helvetica-Bold', 12) + pdf.drawString(margin, y, 'Balance summary') + y -= 0.6 * cm + pdf.setFont('Helvetica', 10) + metrics = [ + f"Opening e-cash: {profile.opening_ecash}", + f"Opening physical: {profile.opening_physical_cash}", + f"Closing e-cash: {snapshot['closing_ecash']}", + f"Closing physical: {snapshot['closing_physical']}", + f"Transactions: {snapshot['total_count']}", + f"Gross amount: {snapshot['total_amount']}", + f"Service fees: {snapshot['total_fees']}", + ] + for item in metrics: + pdf.drawString(margin, y, item) + y -= 0.45 * cm + + y -= 0.2 * cm + pdf.setFont('Helvetica-Bold', 12) + pdf.drawString(margin, y, 'Transaction type totals') + y -= 0.6 * cm + pdf.setFont('Helvetica-Bold', 10) + pdf.drawString(margin, y, 'Type') + pdf.drawString(8.2 * cm, y, 'Count') + pdf.drawString(11 * cm, y, 'Amount') + pdf.drawString(15 * cm, y, 'Fees') + y -= 0.35 * cm + pdf.setStrokeColor(colors.HexColor('#d4dfd6')) + pdf.line(margin, y, width - margin, y) + y -= 0.45 * cm + pdf.setFont('Helvetica', 10) + for row in snapshot['summary']: + if y < 3 * cm: + pdf.showPage() + y = height - margin + pdf.drawString(margin, y, row['label']) + pdf.drawString(8.2 * cm, y, str(row['count'])) + pdf.drawString(11 * cm, y, str(row['amount'])) + pdf.drawString(15 * cm, y, str(row['fees'])) + y -= 0.45 * cm + + y -= 0.3 * cm + pdf.setFont('Helvetica-Bold', 12) + pdf.drawString(margin, y, 'Recent entries') + y -= 0.6 * cm + pdf.setFont('Helvetica', 9) + for entry in snapshot['entries'][:12]: + if y < 2.5 * cm: + pdf.showPage() + y = height - margin + label = f"{timezone.localtime(entry.created_at).strftime('%Y-%m-%d %H:%M')} · {entry.get_transaction_type_display()} · {entry.client_name} · {entry.amount}" + max_width = width - (2 * margin) + while stringWidth(label, 'Helvetica', 9) > max_width and len(label) > 3: + label = label[:-4] + '...' + pdf.drawString(margin, y, label) + y -= 0.42 * cm + + pdf.showPage() + pdf.save() + buffer.seek(0) + filename = f"momo-report-{snapshot['start_date']}-to-{snapshot['end_date']}.pdf" + return FileResponse(buffer, as_attachment=True, filename=filename) + + +def parse_api_payload(request): + if (request.content_type or '').startswith('application/json'): + try: + return json.loads(request.body.decode('utf-8') or '{}') + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return request.POST + + +def serialize_form_errors(form): + errors = {} + for field, items in form.errors.get_json_data().items(): + errors[field] = [item['message'] for item in items] + return errors + + +def build_transaction_summary(profile): + summary = profile.transactions.aggregate( + total_amount=Sum('amount'), + total_fees=Sum('service_charge'), + total_count=Count('id'), + ) + return { + 'total_amount': str(summary['total_amount'] or 0), + 'total_fees': str(summary['total_fees'] or 0), + 'total_count': summary['total_count'] or 0, + } + + +@require_GET +def api_health_view(request): + return JsonResponse({ + 'ok': True, + 'app': 'MoMoLedger API', + 'message': 'Android backend starter endpoints are available.', + 'server_time': timezone.now().isoformat(), + 'authenticated': request.user.is_authenticated, + }) + + +@csrf_exempt +@require_POST +def api_login_view(request): + if request.user.is_authenticated: + profile = get_profile(request.user) + return JsonResponse({ + 'ok': True, + 'message': 'Already logged in.', + 'user': { + 'username': request.user.username, + 'email': request.user.email, + 'business_name': profile.business_name, + }, + }) + + payload = parse_api_payload(request) + if payload is None: + return JsonResponse({ + 'ok': False, + 'error': 'Invalid JSON body.', + }, status=400) + + username = (payload.get('username') or '').strip() + password = payload.get('password') or '' + + if not username or not password: + return JsonResponse({ + 'ok': False, + 'error': 'Username and password are required.', + }, status=400) + + user = authenticate(request, username=username, password=password) + if user is None: + return JsonResponse({ + 'ok': False, + 'error': 'Invalid username or password.', + }, status=401) + + login(request, user) + profile = get_profile(user) + return JsonResponse({ + 'ok': True, + 'message': 'Login successful.', + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'business_name': profile.business_name, + }, + }) + + +@csrf_exempt +@require_POST +def api_logout_view(request): + if not request.user.is_authenticated: + return JsonResponse({ + 'ok': True, + 'message': 'No active session.', + }) + + logout(request) + return JsonResponse({ + 'ok': True, + 'message': 'Logout successful.', + }) + + +@require_GET +@api_login_required +def api_profile_view(request): + profile = get_profile(request.user) + return JsonResponse({ + 'ok': True, + 'profile': { + 'username': request.user.username, + 'email': request.user.email, + 'owner_label': profile.owner_label, + 'business_name': profile.business_name, + 'opening_ecash': str(profile.opening_ecash), + 'opening_physical_cash': str(profile.opening_physical_cash), + 'current_ecash': str(profile.current_ecash), + 'current_physical_cash': str(profile.current_physical_cash), + 'total_cash': str(profile.total_cash), + 'created_at': timezone.localtime(profile.created_at).isoformat(), + 'updated_at': timezone.localtime(profile.updated_at).isoformat(), + }, + }) + + +@csrf_exempt +@api_login_required +@require_http_methods(['GET', 'POST']) +def api_transactions_view(request): + profile = get_profile(request.user) + + if request.method == 'POST': + payload = parse_api_payload(request) + if payload is None: + return JsonResponse({ + 'ok': False, + 'error': 'Invalid JSON body.', + }, status=400) + + form = TransactionForm(payload or None, business=profile) + if not form.is_valid(): + return JsonResponse({ + 'ok': False, + 'error': 'Validation failed.', + 'errors': serialize_form_errors(form), + }, status=400) + + try: + entry = form.save(request.user) + except ValidationError as exc: + return JsonResponse({ + 'ok': False, + 'error': exc.messages[0] if exc.messages else 'Could not create transaction.', + }, status=400) + + profile.refresh_from_db(fields=['current_ecash', 'current_physical_cash']) + return JsonResponse({ + 'ok': True, + 'message': 'Transaction created successfully.', + 'transaction': serialize_transaction(entry), + 'balances': { + 'current_ecash': str(profile.current_ecash), + 'current_physical_cash': str(profile.current_physical_cash), + 'total_cash': str(profile.total_cash), + }, + 'summary': build_transaction_summary(profile), + }, status=201) + + try: + limit = int(request.GET.get('limit', 20)) + except (TypeError, ValueError): + limit = 20 + limit = max(1, min(limit, 100)) + + entries = list(profile.transactions.select_related('created_by')[:limit]) + return JsonResponse({ + 'ok': True, + 'count': len(entries), + 'limit': limit, + 'summary': build_transaction_summary(profile), + 'transactions': [serialize_transaction(entry) for entry in entries], + }) + + +@csrf_exempt +@api_login_required +@require_http_methods(['DELETE']) +def api_transaction_detail_view(request, transaction_id): + profile = get_profile(request.user) + entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id) + deleted_transaction = serialize_transaction(entry) + delete_result = entry.delete_logged_transaction() + profile = delete_result['business'] + return JsonResponse({ + 'ok': True, + 'message': 'Transaction deleted successfully.', + 'deleted_transaction': deleted_transaction, + 'deleted_transaction_id': delete_result['transaction_id'], + 'balances': { + 'current_ecash': str(profile.current_ecash), + 'current_physical_cash': str(profile.current_physical_cash), + 'total_cash': str(profile.total_cash), + }, + 'summary': build_transaction_summary(profile), + }) diff --git a/media/business_logos/IMG_4442.JPG b/media/business_logos/IMG_4442.JPG new file mode 100644 index 0000000..4923256 Binary files /dev/null and b/media/business_logos/IMG_4442.JPG differ diff --git a/requirements.txt b/requirements.txt index e22994c..6cf2a72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Django==5.2.7 mysqlclient==2.2.7 python-dotenv==1.1.1 +reportlab==4.4.1 diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..be2adfe 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,539 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; +:root { + --momo-primary: #0f6f5c; + --momo-primary-dark: #0a5748; + --momo-secondary: #143642; + --momo-accent: #f59e0b; + --momo-coral: #ef6f6c; + --momo-bg: #f4f6f0; + --momo-surface: #ffffff; + --momo-surface-alt: #eef4ef; + --momo-border: #d4dfd6; + --momo-text: #17332f; + --momo-muted: #647874; + --momo-shadow: 0 24px 60px rgba(16, 58, 50, 0.12); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 16px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Inter', system-ui, sans-serif; + background: + radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 30%), + radial-gradient(circle at right top, rgba(15, 111, 92, 0.14), transparent 28%), + linear-gradient(180deg, #f7fbf8 0%, var(--momo-bg) 55%, #eef4ef 100%); + color: var(--momo-text); + min-height: 100vh; +} + +h1, h2, h3, h4, .navbar-brand, .display-title, .section-title, .section-title-sm { + font-family: 'Space Grotesk', 'Inter', sans-serif; + letter-spacing: -0.03em; +} + +a { + text-decoration: none; +} + +.page-shell { + position: relative; + min-height: 100vh; +} + +.site-header { + z-index: 100; +} + +.momo-nav { + background: rgba(20, 54, 66, 0.82); + backdrop-filter: blur(18px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.brand-badge { + width: 2.75rem; + height: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.95rem; + background: linear-gradient(135deg, var(--momo-accent), #ffd279); + color: var(--momo-secondary); + font-weight: 800; + box-shadow: 0 16px 30px rgba(245, 158, 11, 0.25); +} + +.brand-name { + font-size: 1.12rem; + font-weight: 700; + color: #fff; +} + +.brand-subtitle { + color: rgba(255, 255, 255, 0.68); + font-size: 0.78rem; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.nav-link { + color: rgba(255, 255, 255, 0.78) !important; + font-weight: 500; +} + +.nav-link:hover, +.nav-link:focus { + color: #fff !important; +} + +.btn { + border-radius: 999px; + font-weight: 600; + padding: 0.8rem 1.4rem; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.btn:hover, +.btn:focus { + transform: translateY(-1px); +} + +.btn-momo-primary { + background: linear-gradient(135deg, var(--momo-primary), #16a085); + color: #fff; + border: none; + box-shadow: 0 16px 36px rgba(15, 111, 92, 0.24); +} + +.btn-momo-primary:hover, +.btn-momo-primary:focus { + background: linear-gradient(135deg, #11806a, #18b18f); + color: #fff; +} + +.btn-momo-ghost { + background: rgba(255, 255, 255, 0.12); + color: var(--momo-secondary); + border: 1px solid rgba(20, 54, 66, 0.12); + backdrop-filter: blur(12px); +} + +.momo-nav .btn-momo-ghost { + color: #fff; + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.06); +} + +.hero-panel, +.content-card, +.glass-card, +.soft-card, +.balance-card, +.mini-feature, +.metric-card, +.mini-stat { + border: 1px solid rgba(255, 255, 255, 0.54); + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--momo-shadow); +} + +.hero-panel { + position: relative; + padding: 4.25rem clamp(1.4rem, 3vw, 3rem); + border-radius: calc(var(--radius-xl) + 8px); + background: + linear-gradient(135deg, rgba(20, 54, 66, 0.95), rgba(15, 111, 92, 0.84)), + linear-gradient(120deg, rgba(245, 158, 11, 0.2), transparent 42%); + color: #fff; +} + +.display-title { + font-size: clamp(2.6rem, 4vw, 4.5rem); + line-height: 1.02; + margin-bottom: 1.2rem; + max-width: 12ch; +} + +.hero-copy, +.muted-copy { + color: rgba(255, 255, 255, 0.82); + font-size: 1.05rem; + line-height: 1.72; +} + +.eyebrow, +.card-kicker { + display: inline-block; + margin-bottom: 0.85rem; + font-size: 0.84rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--momo-accent); + font-weight: 700; +} + +.hero-metrics .metric-card, +.mini-feature, +.soft-card, +.mini-stat { + border-radius: var(--radius-md); + padding: 1.2rem; +} + +.metric-card strong { + display: block; + font-size: 1.7rem; + color: var(--momo-secondary); + margin-bottom: 0.3rem; +} + +.metric-card span, +.mini-feature span, +.stack-item span, +.preview-metrics span, +.movement-card span, +.detail-grid span, +.rule-item span, +.report-preview-header span, +.mini-stat span { + display: block; + color: var(--momo-muted); + font-size: 0.92rem; + line-height: 1.55; +} + +.metric-card span { + color: #3e514d; +} + +.glass-card { + padding: 1.25rem; + border-radius: var(--radius-xl); + background: rgba(255, 255, 255, 0.14); + backdrop-filter: blur(14px); +} + +.soft-card { + background: rgba(255, 255, 255, 0.92); + height: 100%; +} + +.section-title { + font-size: clamp(1.8rem, 2vw, 2.6rem); + color: var(--momo-secondary); + margin-bottom: 0.75rem; +} + +.section-title-sm { + font-size: 1.32rem; + color: var(--momo-secondary); + margin-bottom: 0.55rem; +} + +.content-card { + border-radius: var(--radius-xl); + padding: clamp(1.35rem, 3vw, 2rem); +} + +.balance-card { + border-radius: var(--radius-lg); + padding: 1.35rem; + min-height: 100%; +} + +.balance-card span, +.mini-stat span { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.75rem; + font-weight: 700; + color: var(--momo-muted); +} + +.balance-card strong, +.mini-stat strong { + display: block; + font-size: 1.9rem; + margin: 0.45rem 0; + color: var(--momo-secondary); +} + +.balance-card small { + color: var(--momo-muted); + font-size: 0.88rem; +} + +.balance-card-primary { + background: linear-gradient(135deg, rgba(15, 111, 92, 0.14), rgba(255, 255, 255, 0.96)); +} + +.balance-card-secondary { + background: linear-gradient(135deg, rgba(20, 54, 66, 0.12), rgba(255, 255, 255, 0.96)); +} + +.balance-card-accent { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.18), rgba(255, 255, 255, 0.96)); +} + +.shape { + position: absolute; + border-radius: 999px; + opacity: 0.22; + pointer-events: none; +} + +.shape-one { + width: 180px; + height: 180px; + right: -36px; + top: -28px; + background: linear-gradient(135deg, var(--momo-accent), #ffe1aa); +} + +.shape-two { + width: 260px; + height: 260px; + right: 14%; + bottom: -120px; + background: linear-gradient(135deg, rgba(255,255,255,0.45), rgba(255,255,255,0.08)); +} + +.empty-state { + text-align: center; + padding: 2.6rem 1.2rem; + border: 1px dashed var(--momo-border); + border-radius: var(--radius-lg); + background: var(--momo-surface-alt); +} + +.momo-table { + --bs-table-bg: transparent; + --bs-table-border-color: rgba(20, 54, 66, 0.08); +} + +.momo-table thead th { + color: var(--momo-muted); + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + border-bottom-width: 1px; +} + +.momo-badge { + background: rgba(15, 111, 92, 0.12); + color: var(--momo-primary-dark); + border-radius: 999px; + padding: 0.55rem 0.75rem; + font-weight: 600; +} + +.text-link { + color: var(--momo-primary); + font-weight: 700; +} + +.stack-list { + display: grid; + gap: 0.9rem; +} + +.stack-item, +.rule-item { + padding: 1rem 1.05rem; + border-radius: 1rem; + background: var(--momo-surface-alt); + border: 1px solid var(--momo-border); +} + +.stack-item strong, +.rule-item strong { + display: block; + margin-bottom: 0.25rem; + color: var(--momo-secondary); +} + +.momo-form .form-label { + font-weight: 700; + color: var(--momo-secondary); + margin-bottom: 0.45rem; +} + +.momo-input, +.momo-file { + border-radius: 1rem; + border: 1px solid rgba(20, 54, 66, 0.14); + padding: 0.92rem 1rem; + background: #fff; + box-shadow: none !important; +} + +.momo-input:focus, +.momo-file:focus, +.btn:focus, +.nav-link:focus { + border-color: rgba(15, 111, 92, 0.5); + box-shadow: 0 0 0 0.25rem rgba(15, 111, 92, 0.12) !important; + outline: none; +} + +.form-help { + margin-top: 0.35rem; + color: var(--momo-muted); + font-size: 0.87rem; +} + +.form-error { + margin-top: 0.4rem; + color: #b4373b; + font-size: 0.9rem; + font-weight: 600; +} + +.profile-mini, +.report-preview-header, +.report-sheet-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; +} + +.profile-mini span, +.report-preview-header span { + font-size: 0.86rem; +} + +.logo-thumb, +.logo-placeholder { + width: 72px; + height: 72px; + border-radius: 1.25rem; + object-fit: cover; + border: 1px solid rgba(20, 54, 66, 0.1); + background: linear-gradient(135deg, rgba(15,111,92,0.14), rgba(245,158,11,0.16)); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--momo-secondary); + font-weight: 700; +} + +.preview-metrics, +.detail-grid, +.movement-card { + display: grid; + gap: 0.9rem; +} + +.preview-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.preview-metrics.two-up, +.detail-grid, +.movement-card { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.preview-metrics div, +.detail-grid div, +.movement-card div, +.report-preview-card, +.mini-stat { + padding: 1rem; + border-radius: 1rem; + background: var(--momo-surface-alt); + border: 1px solid var(--momo-border); +} + +.preview-metrics strong, +.detail-grid strong, +.movement-card strong { + display: block; + color: var(--momo-secondary); + font-size: 1.08rem; + margin-top: 0.35rem; +} + +.report-sheet { + border: 1px solid rgba(20, 54, 66, 0.08); +} + +.mini-stat { + height: 100%; +} + +.page-intro .muted-copy, +.content-card .muted-copy, +.auth-layout .muted-copy { + color: var(--momo-muted); +} + +.auth-layout { + min-height: 66vh; +} + +.compact .stack-item { + padding: 0.85rem 0.95rem; +} + +@media print { + .site-header, + .btn, + .message-stack, + .page-intro, + .report-shortcuts { + display: none !important; + } + + body { + background: #fff; + } + + .container, + .content-card, + .report-sheet { + max-width: none !important; + box-shadow: none !important; + border: none !important; + padding: 0 !important; + } +} + +@media (max-width: 991.98px) { + .display-title { + max-width: 100%; + } + + .profile-mini, + .report-preview-header, + .report-sheet-header, + .preview-metrics, + .preview-metrics.two-up, + .detail-grid, + .movement-card { + grid-template-columns: 1fr; + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 767.98px) { + .hero-panel { + padding: 2rem 1.2rem 2.3rem; + } + + .display-title { + font-size: 2.35rem; + } + + .content-card, + .balance-card { + padding: 1.25rem; + } } diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..be2adfe 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,539 @@ - :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); + --momo-primary: #0f6f5c; + --momo-primary-dark: #0a5748; + --momo-secondary: #143642; + --momo-accent: #f59e0b; + --momo-coral: #ef6f6c; + --momo-bg: #f4f6f0; + --momo-surface: #ffffff; + --momo-surface-alt: #eef4ef; + --momo-border: #d4dfd6; + --momo-text: #17332f; + --momo-muted: #647874; + --momo-shadow: 0 24px 60px rgba(16, 58, 50, 0.12); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 16px; } + +* { + box-sizing: border-box; +} + body { margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; + font-family: 'Inter', system-ui, sans-serif; + background: + radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 30%), + radial-gradient(circle at right top, rgba(15, 111, 92, 0.14), transparent 28%), + linear-gradient(180deg, #f7fbf8 0%, var(--momo-bg) 55%, #eef4ef 100%); + color: var(--momo-text); min-height: 100vh; - text-align: center; - overflow: hidden; - position: relative; +} + +h1, h2, h3, h4, .navbar-brand, .display-title, .section-title, .section-title-sm { + font-family: 'Space Grotesk', 'Inter', sans-serif; + letter-spacing: -0.03em; +} + +a { + text-decoration: none; +} + +.page-shell { + position: relative; + min-height: 100vh; +} + +.site-header { + z-index: 100; +} + +.momo-nav { + background: rgba(20, 54, 66, 0.82); + backdrop-filter: blur(18px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.brand-badge { + width: 2.75rem; + height: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.95rem; + background: linear-gradient(135deg, var(--momo-accent), #ffd279); + color: var(--momo-secondary); + font-weight: 800; + box-shadow: 0 16px 30px rgba(245, 158, 11, 0.25); +} + +.brand-name { + font-size: 1.12rem; + font-weight: 700; + color: #fff; +} + +.brand-subtitle { + color: rgba(255, 255, 255, 0.68); + font-size: 0.78rem; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.nav-link { + color: rgba(255, 255, 255, 0.78) !important; + font-weight: 500; +} + +.nav-link:hover, +.nav-link:focus { + color: #fff !important; +} + +.btn { + border-radius: 999px; + font-weight: 600; + padding: 0.8rem 1.4rem; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.btn:hover, +.btn:focus { + transform: translateY(-1px); +} + +.btn-momo-primary { + background: linear-gradient(135deg, var(--momo-primary), #16a085); + color: #fff; + border: none; + box-shadow: 0 16px 36px rgba(15, 111, 92, 0.24); +} + +.btn-momo-primary:hover, +.btn-momo-primary:focus { + background: linear-gradient(135deg, #11806a, #18b18f); + color: #fff; +} + +.btn-momo-ghost { + background: rgba(255, 255, 255, 0.12); + color: var(--momo-secondary); + border: 1px solid rgba(20, 54, 66, 0.12); + backdrop-filter: blur(12px); +} + +.momo-nav .btn-momo-ghost { + color: #fff; + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.06); +} + +.hero-panel, +.content-card, +.glass-card, +.soft-card, +.balance-card, +.mini-feature, +.metric-card, +.mini-stat { + border: 1px solid rgba(255, 255, 255, 0.54); + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--momo-shadow); +} + +.hero-panel { + position: relative; + padding: 4.25rem clamp(1.4rem, 3vw, 3rem); + border-radius: calc(var(--radius-xl) + 8px); + background: + linear-gradient(135deg, rgba(20, 54, 66, 0.95), rgba(15, 111, 92, 0.84)), + linear-gradient(120deg, rgba(245, 158, 11, 0.2), transparent 42%); + color: #fff; +} + +.display-title { + font-size: clamp(2.6rem, 4vw, 4.5rem); + line-height: 1.02; + margin-bottom: 1.2rem; + max-width: 12ch; +} + +.hero-copy, +.muted-copy { + color: rgba(255, 255, 255, 0.82); + font-size: 1.05rem; + line-height: 1.72; +} + +.eyebrow, +.card-kicker { + display: inline-block; + margin-bottom: 0.85rem; + font-size: 0.84rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--momo-accent); + font-weight: 700; +} + +.hero-metrics .metric-card, +.mini-feature, +.soft-card, +.mini-stat { + border-radius: var(--radius-md); + padding: 1.2rem; +} + +.metric-card strong { + display: block; + font-size: 1.7rem; + color: var(--momo-secondary); + margin-bottom: 0.3rem; +} + +.metric-card span, +.mini-feature span, +.stack-item span, +.preview-metrics span, +.movement-card span, +.detail-grid span, +.rule-item span, +.report-preview-header span, +.mini-stat span { + display: block; + color: var(--momo-muted); + font-size: 0.92rem; + line-height: 1.55; +} + +.metric-card span { + color: #3e514d; +} + +.glass-card { + padding: 1.25rem; + border-radius: var(--radius-xl); + background: rgba(255, 255, 255, 0.14); + backdrop-filter: blur(14px); +} + +.soft-card { + background: rgba(255, 255, 255, 0.92); + height: 100%; +} + +.section-title { + font-size: clamp(1.8rem, 2vw, 2.6rem); + color: var(--momo-secondary); + margin-bottom: 0.75rem; +} + +.section-title-sm { + font-size: 1.32rem; + color: var(--momo-secondary); + margin-bottom: 0.55rem; +} + +.content-card { + border-radius: var(--radius-xl); + padding: clamp(1.35rem, 3vw, 2rem); +} + +.balance-card { + border-radius: var(--radius-lg); + padding: 1.35rem; + min-height: 100%; +} + +.balance-card span, +.mini-stat span { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.75rem; + font-weight: 700; + color: var(--momo-muted); +} + +.balance-card strong, +.mini-stat strong { + display: block; + font-size: 1.9rem; + margin: 0.45rem 0; + color: var(--momo-secondary); +} + +.balance-card small { + color: var(--momo-muted); + font-size: 0.88rem; +} + +.balance-card-primary { + background: linear-gradient(135deg, rgba(15, 111, 92, 0.14), rgba(255, 255, 255, 0.96)); +} + +.balance-card-secondary { + background: linear-gradient(135deg, rgba(20, 54, 66, 0.12), rgba(255, 255, 255, 0.96)); +} + +.balance-card-accent { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.18), rgba(255, 255, 255, 0.96)); +} + +.shape { + position: absolute; + border-radius: 999px; + opacity: 0.22; + pointer-events: none; +} + +.shape-one { + width: 180px; + height: 180px; + right: -36px; + top: -28px; + background: linear-gradient(135deg, var(--momo-accent), #ffe1aa); +} + +.shape-two { + width: 260px; + height: 260px; + right: 14%; + bottom: -120px; + background: linear-gradient(135deg, rgba(255,255,255,0.45), rgba(255,255,255,0.08)); +} + +.empty-state { + text-align: center; + padding: 2.6rem 1.2rem; + border: 1px dashed var(--momo-border); + border-radius: var(--radius-lg); + background: var(--momo-surface-alt); +} + +.momo-table { + --bs-table-bg: transparent; + --bs-table-border-color: rgba(20, 54, 66, 0.08); +} + +.momo-table thead th { + color: var(--momo-muted); + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + border-bottom-width: 1px; +} + +.momo-badge { + background: rgba(15, 111, 92, 0.12); + color: var(--momo-primary-dark); + border-radius: 999px; + padding: 0.55rem 0.75rem; + font-weight: 600; +} + +.text-link { + color: var(--momo-primary); + font-weight: 700; +} + +.stack-list { + display: grid; + gap: 0.9rem; +} + +.stack-item, +.rule-item { + padding: 1rem 1.05rem; + border-radius: 1rem; + background: var(--momo-surface-alt); + border: 1px solid var(--momo-border); +} + +.stack-item strong, +.rule-item strong { + display: block; + margin-bottom: 0.25rem; + color: var(--momo-secondary); +} + +.momo-form .form-label { + font-weight: 700; + color: var(--momo-secondary); + margin-bottom: 0.45rem; +} + +.momo-input, +.momo-file { + border-radius: 1rem; + border: 1px solid rgba(20, 54, 66, 0.14); + padding: 0.92rem 1rem; + background: #fff; + box-shadow: none !important; +} + +.momo-input:focus, +.momo-file:focus, +.btn:focus, +.nav-link:focus { + border-color: rgba(15, 111, 92, 0.5); + box-shadow: 0 0 0 0.25rem rgba(15, 111, 92, 0.12) !important; + outline: none; +} + +.form-help { + margin-top: 0.35rem; + color: var(--momo-muted); + font-size: 0.87rem; +} + +.form-error { + margin-top: 0.4rem; + color: #b4373b; + font-size: 0.9rem; + font-weight: 600; +} + +.profile-mini, +.report-preview-header, +.report-sheet-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; +} + +.profile-mini span, +.report-preview-header span { + font-size: 0.86rem; +} + +.logo-thumb, +.logo-placeholder { + width: 72px; + height: 72px; + border-radius: 1.25rem; + object-fit: cover; + border: 1px solid rgba(20, 54, 66, 0.1); + background: linear-gradient(135deg, rgba(15,111,92,0.14), rgba(245,158,11,0.16)); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--momo-secondary); + font-weight: 700; +} + +.preview-metrics, +.detail-grid, +.movement-card { + display: grid; + gap: 0.9rem; +} + +.preview-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.preview-metrics.two-up, +.detail-grid, +.movement-card { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.preview-metrics div, +.detail-grid div, +.movement-card div, +.report-preview-card, +.mini-stat { + padding: 1rem; + border-radius: 1rem; + background: var(--momo-surface-alt); + border: 1px solid var(--momo-border); +} + +.preview-metrics strong, +.detail-grid strong, +.movement-card strong { + display: block; + color: var(--momo-secondary); + font-size: 1.08rem; + margin-top: 0.35rem; +} + +.report-sheet { + border: 1px solid rgba(20, 54, 66, 0.08); +} + +.mini-stat { + height: 100%; +} + +.page-intro .muted-copy, +.content-card .muted-copy, +.auth-layout .muted-copy { + color: var(--momo-muted); +} + +.auth-layout { + min-height: 66vh; +} + +.compact .stack-item { + padding: 0.85rem 0.95rem; +} + +@media print { + .site-header, + .btn, + .message-stack, + .page-intro, + .report-shortcuts { + display: none !important; + } + + body { + background: #fff; + } + + .container, + .content-card, + .report-sheet { + max-width: none !important; + box-shadow: none !important; + border: none !important; + padding: 0 !important; + } +} + +@media (max-width: 991.98px) { + .display-title { + max-width: 100%; + } + + .profile-mini, + .report-preview-header, + .report-sheet-header, + .preview-metrics, + .preview-metrics.two-up, + .detail-grid, + .movement-card { + grid-template-columns: 1fr; + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 767.98px) { + .hero-panel { + padding: 2rem 1.2rem 2.3rem; + } + + .display-title { + font-size: 2.35rem; + } + + .content-card, + .balance-card { + padding: 1.25rem; + } }