Autosave: 20260417-025218
This commit is contained in:
parent
640537c868
commit
22e473eb14
Binary file not shown.
Binary file not shown.
@ -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'
|
||||
|
||||
@ -1,23 +1,7 @@
|
||||
"""
|
||||
URL configuration for config project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
@ -25,5 +9,6 @@ urlpatterns = [
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,17 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
from .models import BusinessProfile, Transaction
|
||||
|
||||
|
||||
@admin.register(BusinessProfile)
|
||||
class BusinessProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('business_name', 'user', 'opening_ecash', 'opening_physical_cash', 'current_ecash', 'current_physical_cash')
|
||||
search_fields = ('business_name', 'user__username', 'user__email')
|
||||
|
||||
|
||||
@admin.register(Transaction)
|
||||
class TransactionAdmin(admin.ModelAdmin):
|
||||
list_display = ('client_name', 'transaction_type', 'amount', 'service_charge', 'business', 'created_at')
|
||||
list_filter = ('transaction_type', 'created_at')
|
||||
search_fields = ('client_name', 'business__business_name', 'created_by__username')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
154
core/forms.py
Normal file
154
core/forms.py
Normal file
@ -0,0 +1,154 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import BusinessProfile, Transaction
|
||||
|
||||
|
||||
INPUT_CLASS = 'form-control form-control-lg momo-input'
|
||||
SELECT_CLASS = 'form-select form-select-lg momo-input'
|
||||
|
||||
|
||||
class SignUpForm(UserCreationForm):
|
||||
first_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'First name'}))
|
||||
last_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Last name'}))
|
||||
email = forms.EmailField(required=True, widget=forms.EmailInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Email address'}))
|
||||
|
||||
class Meta(UserCreationForm.Meta):
|
||||
model = User
|
||||
fields = ('first_name', 'last_name', 'username', 'email')
|
||||
widgets = {
|
||||
'username': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['username'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Username'})
|
||||
self.fields['password1'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Password'})
|
||||
self.fields['password2'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Confirm password'})
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
user.first_name = self.cleaned_data['first_name']
|
||||
user.last_name = self.cleaned_data['last_name']
|
||||
user.email = self.cleaned_data['email']
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
username = forms.CharField(widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'}))
|
||||
password = forms.CharField(widget=forms.PasswordInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Password'}))
|
||||
|
||||
|
||||
class BusinessProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = BusinessProfile
|
||||
fields = ['business_name', 'logo', 'opening_ecash', 'opening_physical_cash']
|
||||
widgets = {
|
||||
'business_name': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'e.g. BrightPay MoMo Point'}),
|
||||
'logo': forms.ClearableFileInput(attrs={'class': 'form-control momo-file', 'accept': 'image/*'}),
|
||||
'opening_ecash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}),
|
||||
'opening_physical_cash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}),
|
||||
}
|
||||
help_texts = {
|
||||
'opening_ecash': 'Used as your starting e-cash wallet for the first setup.',
|
||||
'opening_physical_cash': 'Used as your starting notes/coins balance for the first setup.',
|
||||
}
|
||||
|
||||
def save(self, commit=True):
|
||||
profile = super().save(commit=False)
|
||||
should_sync = not profile.pk or not profile.transactions.exists()
|
||||
if should_sync:
|
||||
profile.sync_current_to_opening()
|
||||
if commit:
|
||||
profile.save()
|
||||
return profile
|
||||
|
||||
|
||||
class TransactionForm(forms.Form):
|
||||
client_name = forms.CharField(max_length=120, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Client name'}))
|
||||
amount = forms.DecimalField(max_digits=12, decimal_places=2, min_value=Decimal('0.01'), widget=forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0.01'}))
|
||||
transaction_type = forms.ChoiceField(choices=Transaction.TYPE_CHOICES, widget=forms.Select(attrs={'class': SELECT_CLASS}))
|
||||
notes = forms.CharField(required=False, widget=forms.Textarea(attrs={'class': 'form-control momo-input', 'rows': 3, 'placeholder': 'Optional note about the transaction'}))
|
||||
|
||||
def __init__(self, *args, business=None, **kwargs):
|
||||
self.business = business
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
transaction_type = cleaned_data.get('transaction_type')
|
||||
amount = cleaned_data.get('amount')
|
||||
if not self.business or not transaction_type or amount is None:
|
||||
return cleaned_data
|
||||
|
||||
ecash_delta, physical_delta, _ = Transaction.calculate_effect(transaction_type, amount)
|
||||
if self.business.current_ecash + ecash_delta < 0:
|
||||
self.add_error('amount', 'Not enough e-cash for this transaction.')
|
||||
if self.business.current_physical_cash + physical_delta < 0:
|
||||
self.add_error('amount', 'Not enough physical cash for this transaction.')
|
||||
return cleaned_data
|
||||
|
||||
def save(self, user):
|
||||
if not self.business:
|
||||
raise ValidationError('Business profile is required.')
|
||||
return Transaction.create_logged_transaction(
|
||||
business=self.business,
|
||||
user=user,
|
||||
client_name=self.cleaned_data['client_name'],
|
||||
amount=self.cleaned_data['amount'],
|
||||
transaction_type=self.cleaned_data['transaction_type'],
|
||||
notes=self.cleaned_data['notes'],
|
||||
)
|
||||
|
||||
|
||||
class ReportFilterForm(forms.Form):
|
||||
PERIOD_CHOICES = [
|
||||
('daily', 'Daily'),
|
||||
('weekly', 'Weekly'),
|
||||
('monthly', 'Monthly'),
|
||||
('yearly', 'Yearly'),
|
||||
('custom', 'Custom range'),
|
||||
]
|
||||
|
||||
period = forms.ChoiceField(choices=PERIOD_CHOICES, initial='daily', widget=forms.Select(attrs={'class': 'form-select momo-input'}))
|
||||
start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'}))
|
||||
end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'}))
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
period = cleaned_data.get('period')
|
||||
start_date = cleaned_data.get('start_date')
|
||||
end_date = cleaned_data.get('end_date')
|
||||
if period == 'custom':
|
||||
if not start_date or not end_date:
|
||||
raise forms.ValidationError('Choose both a start and end date for a custom report.')
|
||||
if end_date < start_date:
|
||||
raise forms.ValidationError('End date cannot be before start date.')
|
||||
return cleaned_data
|
||||
|
||||
def get_range(self):
|
||||
today = timezone.localdate()
|
||||
period = self.cleaned_data.get('period') or 'daily'
|
||||
if period == 'daily':
|
||||
return today, today
|
||||
if period == 'weekly':
|
||||
start = today - timezone.timedelta(days=today.weekday())
|
||||
return start, start + timezone.timedelta(days=6)
|
||||
if period == 'monthly':
|
||||
start = today.replace(day=1)
|
||||
if start.month == 12:
|
||||
next_month = start.replace(year=start.year + 1, month=1, day=1)
|
||||
else:
|
||||
next_month = start.replace(month=start.month + 1, day=1)
|
||||
return start, next_month - timezone.timedelta(days=1)
|
||||
if period == 'yearly':
|
||||
start = today.replace(month=1, day=1)
|
||||
return start, today.replace(month=12, day=31)
|
||||
return self.cleaned_data['start_date'], self.cleaned_data['end_date']
|
||||
57
core/migrations/0001_initial.py
Normal file
57
core/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
197
core/models.py
197
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,
|
||||
}
|
||||
|
||||
@ -1,25 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}MoMoLedger{% endblock %}</title>
|
||||
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description }}{% endblock %}">
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
<meta name="author" content="MoMoLedger">
|
||||
<meta name="keywords" content="momo, mobile money, cash-in, cash-out, wallet report">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
<div class="page-shell">
|
||||
<header class="site-header sticky-top">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark px-3 px-lg-4 py-3 momo-nav">
|
||||
<div class="container-fluid px-0">
|
||||
<a class="navbar-brand d-flex align-items-center gap-3" href="{% url 'home' %}">
|
||||
<span class="brand-badge">M</span>
|
||||
<span>
|
||||
<span class="brand-name">MoMoLedger</span>
|
||||
<small class="brand-subtitle d-block">Agent cashflow cockpit</small>
|
||||
</span>
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Home</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'transaction_list' %}">Transactions</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'reports' %}">Reports</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'profile' %}">Business setup</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item ms-lg-3"><a class="btn btn-momo-ghost" href="{% url 'logout' %}">Log out</a></li>
|
||||
{% else %}
|
||||
<li class="nav-item ms-lg-3"><a class="btn btn-momo-ghost" href="{% url 'login' %}">Log in</a></li>
|
||||
<li class="nav-item"><a class="btn btn-momo-primary" href="{% url 'signup' %}">Sign up</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="container py-4 py-lg-5">
|
||||
{% if messages %}
|
||||
<div class="message-stack mb-4">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags|default:'info' }} shadow-sm border-0" role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
47
core/templates/core/auth.html
Normal file
47
core/templates/core/auth.html
Normal 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 %}
|
||||
@ -1,145 +1,200 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ project_name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes bg-pan {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 1.2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 1.5rem auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.runtime code {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block title %}{{ page_title }}{% endblock %}
|
||||
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your app…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<section class="hero-panel mb-5 overflow-hidden">
|
||||
<div class="row align-items-center g-4 g-lg-5">
|
||||
<div class="col-lg-7">
|
||||
<span class="eyebrow">Mobile money bookkeeping made clear</span>
|
||||
<h1 class="display-title">Run your MoMo business with branded reports, balanced wallets, and cleaner daily records.</h1>
|
||||
<p class="hero-copy">Track e-cash, physical cash, cash-in, cash-out, sending, airtime, transfer, debt, expenditure, and credit in one polished workflow. MoMoLedger updates balances automatically and keeps your daily, weekly, monthly, and yearly reports ready to print or export as PDF.</p>
|
||||
<div class="d-flex flex-wrap gap-3 mt-4">
|
||||
{% if request.user.is_authenticated %}
|
||||
<a class="btn btn-momo-primary btn-lg" href="{% url 'transaction_create' %}">Record a transaction</a>
|
||||
<a class="btn btn-momo-ghost btn-lg" href="{% url 'reports' %}">Open reports</a>
|
||||
{% else %}
|
||||
<a class="btn btn-momo-primary btn-lg" href="{% url 'signup' %}">Create agent account</a>
|
||||
<a class="btn btn-momo-ghost btn-lg" href="{% url 'login' %}">Sign in</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="hero-metrics row row-cols-1 row-cols-sm-3 g-3 mt-4">
|
||||
<div class="col"><div class="metric-card"><strong>8</strong><span>Transaction types with smart balance rules</span></div></div>
|
||||
<div class="col"><div class="metric-card"><strong>1%</strong><span>Sending fee calculated automatically</span></div></div>
|
||||
<div class="col"><div class="metric-card"><strong>4</strong><span>Report ranges: day, week, month, year</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="glass-card auth-showcase">
|
||||
<div class="row g-3 g-lg-4">
|
||||
<div class="col-md-6 col-lg-12 col-xl-6">
|
||||
<div class="soft-card h-100">
|
||||
<p class="card-kicker">Welcome page</p>
|
||||
<h2 class="section-title-sm">Sign up & login</h2>
|
||||
<p class="muted-copy">Start with a secure agent account, then return to your dashboard anytime.</p>
|
||||
<div class="d-grid gap-2 mt-3">
|
||||
<a class="btn btn-momo-primary" href="{% url 'signup' %}">Sign up</a>
|
||||
<a class="btn btn-momo-ghost" href="{% url 'login' %}">Log in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-12 col-xl-6">
|
||||
<div class="soft-card h-100">
|
||||
<p class="card-kicker">Brand your reports</p>
|
||||
<h2 class="section-title-sm">Business setup</h2>
|
||||
<p class="muted-copy">Add your business name, logo, and opening balances so every report looks official.</p>
|
||||
{% if request.user.is_authenticated and profile %}
|
||||
<div class="profile-mini mt-3">
|
||||
<div>
|
||||
<strong>{{ profile.business_name|default:'Business name not set' }}</strong>
|
||||
<span>{{ profile.owner_label }}</span>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-momo-ghost" href="{% url 'profile' %}">Edit</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<a class="btn btn-momo-ghost mt-3" href="{% url 'signup' %}">Create account to set profile</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
||||
<p class="runtime">
|
||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
<div class="shape shape-one"></div>
|
||||
<div class="shape shape-two"></div>
|
||||
</section>
|
||||
|
||||
{% if request.user.is_authenticated and profile %}
|
||||
<section class="dashboard-grid mb-5">
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="content-card mb-4">
|
||||
<div class="section-header d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||
<div>
|
||||
<p class="card-kicker mb-1">Live balances</p>
|
||||
<h2 class="section-title mb-0">{{ profile.business_name|default:'Your business dashboard' }}</h2>
|
||||
</div>
|
||||
<a class="btn btn-momo-primary" href="{% url 'transaction_create' %}">New transaction</a>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<article class="balance-card balance-card-primary">
|
||||
<span>E-cash</span>
|
||||
<strong>{{ profile.current_ecash }}</strong>
|
||||
<small>Opening {{ profile.opening_ecash }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<article class="balance-card balance-card-secondary">
|
||||
<span>Physical cash</span>
|
||||
<strong>{{ profile.current_physical_cash }}</strong>
|
||||
<small>Opening {{ profile.opening_physical_cash }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<article class="balance-card balance-card-accent">
|
||||
<span>Today’s 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 %}
|
||||
|
||||
60
core/templates/core/profile_form.html
Normal file
60
core/templates/core/profile_form.html
Normal 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 %}
|
||||
127
core/templates/core/reports.html
Normal file
127
core/templates/core/reports.html
Normal 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 %}
|
||||
41
core/templates/core/transaction_detail.html
Normal file
41
core/templates/core/transaction_detail.html
Normal 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 %}
|
||||
48
core/templates/core/transaction_form.html
Normal file
48
core/templates/core/transaction_form.html
Normal 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 %}
|
||||
58
core/templates/core/transaction_list.html
Normal file
58
core/templates/core/transaction_list.html
Normal 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 %}
|
||||
@ -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'))
|
||||
|
||||
36
core/urls.py
36
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/<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'),
|
||||
]
|
||||
|
||||
554
core/views.py
554
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),
|
||||
})
|
||||
|
||||
BIN
media/business_logos/IMG_4442.JPG
Normal file
BIN
media/business_logos/IMG_4442.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@ -1,3 +1,4 @@
|
||||
Django==5.2.7
|
||||
mysqlclient==2.2.7
|
||||
python-dotenv==1.1.1
|
||||
reportlab==4.4.1
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user