diff --git a/config/django/base.py b/config/django/base.py
index 91f41d89..25ab2a30 100644
--- a/config/django/base.py
+++ b/config/django/base.py
@@ -51,7 +51,8 @@
'open_schools_platform.ticket_management.tickets.apps.TicketsConfig',
'open_schools_platform.organization_management.teachers.apps.TeachersConfig',
'open_schools_platform.testing.apps.TestingConfig',
- 'open_schools_platform.sms.apps.SmsConfig'
+ 'open_schools_platform.sms.apps.SmsConfig',
+ 'open_schools_platform.receipt_management.receipts.apps.ReceiptsConfig'
]
THIRD_PARTY_APPS = [
diff --git a/open_schools_platform/api/swagger_tags.py b/open_schools_platform/api/swagger_tags.py
index c9d0a0d8..78941d28 100644
--- a/open_schools_platform/api/swagger_tags.py
+++ b/open_schools_platform/api/swagger_tags.py
@@ -13,3 +13,4 @@ class SwaggerTags:
PHOTO_MANAGEMENT_PHOTOS = "Photos management. Photos"
HISTORY_MANAGEMENT = "History management"
TICKET_MANAGEMENT_TICKET = "Ticket management. Tickets"
+ RECEIPT_MANAGEMENT_RECEIPTS = "Receipt management. Receipts"
diff --git a/open_schools_platform/api/urls.py b/open_schools_platform/api/urls.py
index 639ecce0..6a0248e7 100644
--- a/open_schools_platform/api/urls.py
+++ b/open_schools_platform/api/urls.py
@@ -45,6 +45,10 @@
path('ticket', include(('open_schools_platform.ticket_management.tickets.urls', 'ticket'))),
]
+receipt_management_urls = [
+ path('receipts', include(('open_schools_platform.receipt_management.receipts.urls', 'receipts'))),
+]
+
urlpatterns = [
path('user-management/', include((user_management_urls, 'user-management'))),
path('organization-management/', include((organization_management_urls, 'organization-management'))),
@@ -53,7 +57,8 @@
path('students-management/', include((students_management_urls, 'students-management'))),
path('photos-management/', include((photos_management_urls, 'photo-management'))),
path('history-management/', include((history_management_urls, 'history-management'))),
- path('ticket-management/', include((ticket_management_urls, 'ticket-management')))
+ path('ticket-management/', include((ticket_management_urls, 'ticket-management'))),
+ path('receipt-management/', include((receipt_management_urls, 'receipt-management')))
]
if settings.SWAGGER_ENABLED:
diff --git a/open_schools_platform/receipt_management/__init__.py b/open_schools_platform/receipt_management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/open_schools_platform/receipt_management/receipts/__init__.py b/open_schools_platform/receipt_management/receipts/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/open_schools_platform/receipt_management/receipts/admin.py b/open_schools_platform/receipt_management/receipts/admin.py
new file mode 100644
index 00000000..d481d8eb
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/admin.py
@@ -0,0 +1,105 @@
+from django.contrib import admin
+from django.utils.html import format_html
+
+from .models import Receipt, ReceiptNotification
+
+
+@admin.register(Receipt)
+class ReceiptAdmin(admin.ModelAdmin):
+ list_display = [
+ 'internal_receipt_number', 'recipient_full_name', 'student_profile_name',
+ 'institution_name', 'total_amount', 'payment_due_date',
+ 'created_at', 'pdf_file_link'
+ ]
+ list_filter = [
+ 'payment_due_date', 'created_at', 'institution_name',
+ 'service_category'
+ ]
+ search_fields = [
+ 'internal_receipt_number', 'recipient_full_name', 'payer_full_name',
+ 'institution_name', 'service_name', 'student_profile__user__first_name',
+ 'student_profile__user__last_name' ]
+ readonly_fields = [
+ 'id', 'created_at', 'updated_at'
+ ]
+ fieldsets = (
+ ('Student Information', {
+ 'fields': ('student_profile', 'recipient_full_name', 'payer_full_name')
+ }),
+ ('Receipt Details', {
+ 'fields': ('internal_receipt_number', 'institution_name', 'service_name',
+ 'service_category', 'payment_purpose')
+ }),
+ ('Financial Information', {
+ 'fields': ('debt_at_month_start', 'recalculation_amount', 'paid_amount',
+ 'debt_at_next_month_start', 'prepayment', 'service_amount', 'total_amount')
+ }),
+ ('Dates', {
+ 'fields': ('receipt_date', 'payment_due_date')
+ }),
+ ('Files & QR Codes', {
+ 'fields': ('pdf_file', 'qr_code_data') }),
+ ('Timestamps', {
+ 'fields': ('created_at', 'updated_at'),
+ 'classes': ('collapse',)
+ })
+ )
+
+ def student_profile_name(self, obj):
+ """Display student name from profile"""
+ if obj.student_profile:
+ return f"{obj.student_profile.user.first_name} {obj.student_profile.user.last_name}"
+ return "N/A"
+
+ student_profile_name.short_description = "Student Name"
+
+ def pdf_file_link(self, obj):
+ if obj.pdf_file:
+ return format_html(
+ 'Download PDF',
+ obj.pdf_file.url
+ )
+ return "No PDF"
+
+ pdf_file_link.short_description = "PDF File"
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).select_related('student_profile__user')
+
+
+@admin.register(ReceiptNotification)
+class ReceiptNotificationAdmin(admin.ModelAdmin):
+ list_display = [
+ 'id', 'receipt_number', 'notification_type',
+ 'is_delivered', 'sent_at', 'created_at'
+ ]
+ list_filter = [
+ 'notification_type', 'is_delivered', 'sent_at', 'created_at'
+ ]
+ search_fields = [
+ 'receipt__internal_receipt_number', 'receipt__recipient_full_name',
+ 'receipt__payer_full_name', 'receipt__institution_name'
+ ]
+ readonly_fields = ['id', 'created_at', 'updated_at', 'sent_at']
+
+ fieldsets = (
+ ('Notification Details', {
+ 'fields': ('receipt', 'notification_type')
+ }),
+ ('Status', {
+ 'fields': ('is_delivered', 'sent_at')
+ }),
+ ('Timestamps', {
+ 'fields': ('created_at', 'updated_at'),
+ 'classes': ('collapse',)
+ })
+ )
+
+ def receipt_number(self, obj):
+ """Display receipt number"""
+ return obj.receipt.internal_receipt_number if obj.receipt else "N/A"
+
+ receipt_number.short_description = "Receipt Number"
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).select_related('receipt')
diff --git a/open_schools_platform/receipt_management/receipts/apps.py b/open_schools_platform/receipt_management/receipts/apps.py
new file mode 100644
index 00000000..b5f2523e
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ReceiptsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'open_schools_platform.receipt_management.receipts'
diff --git a/open_schools_platform/receipt_management/receipts/filters.py b/open_schools_platform/receipt_management/receipts/filters.py
new file mode 100644
index 00000000..656bb1f5
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/filters.py
@@ -0,0 +1,68 @@
+from django_filters import CharFilter, BooleanFilter, DateFilter, ChoiceFilter, NumberFilter
+
+from open_schools_platform.common.filters import BaseFilterSet, UUIDInFilter, filter_by_ids, MetaCharIContainsMixin
+from open_schools_platform.receipt_management.receipts.models import Receipt, ReceiptNotification
+
+
+class ReceiptFilter(BaseFilterSet):
+ ids = CharFilter(method=filter_by_ids)
+ or_search = CharFilter(field_name="or_search", method="OR")
+
+ payer_full_name = CharFilter(field_name="payer_full_name", lookup_expr="icontains")
+ recipient_full_name = CharFilter(field_name="recipient_full_name", lookup_expr="icontains")
+ student_profile = UUIDInFilter(field_name="student_profile", lookup_expr="in")
+
+ institution_name = CharFilter(field_name="institution_name", lookup_expr="icontains")
+ service_name = CharFilter(field_name="service_name", lookup_expr="icontains")
+ service_category = CharFilter(field_name="service_category", lookup_expr="icontains")
+
+ total_amount_min = NumberFilter(field_name="total_amount", lookup_expr="gte")
+ total_amount_max = NumberFilter(field_name="total_amount", lookup_expr="lte")
+ service_amount_min = NumberFilter(field_name="service_amount", lookup_expr="gte")
+ service_amount_max = NumberFilter(field_name="service_amount", lookup_expr="lte")
+
+ payment_due_date_from = DateFilter(field_name="payment_due_date", lookup_expr="gte")
+ payment_due_date_to = DateFilter(field_name="payment_due_date", lookup_expr="lte")
+ receipt_date_from = DateFilter(field_name="receipt_date", lookup_expr="gte")
+ receipt_date_to = DateFilter(field_name="receipt_date", lookup_expr="lte")
+ created_at_from = DateFilter(field_name="created_at", lookup_expr="gte")
+ created_at_to = DateFilter(field_name="created_at", lookup_expr="lte")
+ internal_receipt_number = CharFilter(field_name="internal_receipt_number", lookup_expr="icontains")
+
+ payment_purpose = CharFilter(field_name="payment_purpose", lookup_expr="icontains")
+
+ class Meta(MetaCharIContainsMixin):
+ model = Receipt
+ fields = [
+ 'id', 'payer_full_name', 'recipient_full_name', 'student_profile',
+ 'institution_name', 'service_name', 'service_category',
+ 'total_amount', 'service_amount', 'payment_due_date', 'receipt_date',
+ 'internal_receipt_number', 'payment_purpose',
+ 'created_at', 'updated_at'
+ ]
+
+
+class ReceiptNotificationFilter(BaseFilterSet):
+ or_search = CharFilter(field_name="or_search", method="OR")
+
+ receipt_recipient_name = CharFilter(field_name="receipt__recipient_full_name", lookup_expr="icontains")
+ receipt_institution = CharFilter(field_name="receipt__institution_name", lookup_expr="icontains")
+ receipt_service = CharFilter(field_name="receipt__service_name", lookup_expr="icontains")
+
+ notification_type = ChoiceFilter(
+ field_name="notification_type",
+ choices=ReceiptNotification.NOTIFICATION_TYPES
+ )
+ is_delivered = BooleanFilter(field_name="is_delivered")
+
+ created_at_from = DateFilter(field_name="created_at", lookup_expr="gte")
+ created_at_to = DateFilter(field_name="created_at", lookup_expr="lte")
+ sent_at_from = DateFilter(field_name="sent_at", lookup_expr="gte")
+ sent_at_to = DateFilter(field_name="sent_at", lookup_expr="lte")
+
+ class Meta(MetaCharIContainsMixin):
+ model = ReceiptNotification
+ fields = [
+ 'id', 'receipt', 'notification_type', 'is_delivered',
+ 'created_at', 'updated_at', 'sent_at'
+ ]
diff --git a/open_schools_platform/receipt_management/receipts/migrations/0001_initial.py b/open_schools_platform/receipt_management/receipts/migrations/0001_initial.py
new file mode 100644
index 00000000..0ea21979
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/migrations/0001_initial.py
@@ -0,0 +1,225 @@
+# Generated by Django 3.2.12 on 2025-05-23 17:28
+
+import uuid
+from decimal import Decimal
+
+import django.core.validators
+import django.utils.timezone
+import rules.contrib.models
+import simple_history.models
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ('students', '0007_historicalstudent'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Receipt',
+ fields=[
+ ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)),
+ ('deleted_by_cascade', models.BooleanField(default=False, editable=False)),
+ ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
+ ('internal_receipt_number',
+ models.CharField(help_text='Уникальный идентификатор платёжной записи внутри учётной системы',
+ max_length=100)),
+ ('payer_full_name',
+ models.CharField(help_text='ФИО физического лица, производящего оплату', max_length=300)),
+ ('recipient_full_name',
+ models.CharField(help_text='ФИО обучающегося / лица, за которого осуществляется платёж',
+ max_length=300)),
+ ('institution_name',
+ models.CharField(help_text='Учебное учреждение, оказывающее услугу', max_length=500)),
+ ('service_name',
+ models.CharField(help_text='Наименование конкретной оплачиваемой услуги', max_length=300)),
+ ('service_category',
+ models.CharField(blank=True, help_text='Группировка или категория услуги', max_length=200)),
+ ('debt_at_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Остаток долга на первое число текущего месяца',
+ max_digits=10)),
+ ('recalculation_amount',
+ models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Изменение начислений',
+ max_digits=10)),
+ ('paid_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Сколько уже заплатили за услугу', max_digits=10)),
+ ('debt_at_next_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Остаток долга на начало следующего месяца',
+ max_digits=10)),
+ ('prepayment',
+ models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Деньги, внесенные вперед',
+ max_digits=10)),
+ ('service_amount', models.DecimalField(decimal_places=2,
+ help_text='Фактическая стоимость конкретной услуги за расчетный период',
+ max_digits=10, validators=[
+ django.core.validators.MinValueValidator(Decimal('0.00'))])),
+ ('total_amount',
+ models.DecimalField(decimal_places=2, help_text='Конечный итог суммы к оплате', max_digits=10,
+ validators=[django.core.validators.MinValueValidator(Decimal('0.00'))])),
+ ('receipt_date', models.DateField(help_text='Дата создания квитанции')),
+ ('payment_due_date', models.DateField(help_text='Крайний срок оплаты')),
+ ('payment_purpose', models.TextField(help_text='Показывает основание для оплаты')),
+ ('qr_code_data', models.TextField(blank=True,
+ help_text='QR-код данные для быстрой оплаты через банковские приложения')),
+ ('barcode_data',
+ models.TextField(blank=True, help_text='Штрихкодовая строка для автоматического считывания')),
+ ('is_viewed', models.BooleanField(default=False, help_text='Была ли просмотрена квитанция родителем')),
+ ('viewed_at', models.DateTimeField(blank=True, help_text='Время просмотра квитанции', null=True)),
+ ('pdf_file',
+ models.FileField(blank=True, help_text='PDF файл квитанции', null=True, upload_to='receipts/pdfs/')),
+ ('student_profile',
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receipts',
+ to='students.studentprofile')),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ },
+ bases=(rules.contrib.models.RulesModelMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='ReceiptNotification',
+ fields=[
+ ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)),
+ ('deleted_by_cascade', models.BooleanField(default=False, editable=False)),
+ ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
+ ('notification_type', models.CharField(
+ choices=[('initial', 'Initial Receipt Notification'), ('reminder', 'Payment Reminder'),
+ ('overdue', 'Overdue Payment Notice')], max_length=20)),
+ ('sent_at', models.DateTimeField(auto_now_add=True)),
+ ('is_delivered', models.BooleanField(default=False)),
+ ('receipt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications',
+ to='receipts.receipt')),
+ ],
+ options={
+ 'ordering': ['-sent_at'],
+ },
+ bases=(rules.contrib.models.RulesModelMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='HistoricalReceiptNotification',
+ fields=[
+ ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)),
+ ('deleted_by_cascade', models.BooleanField(default=False, editable=False)),
+ ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
+ ('updated_at', models.DateTimeField(blank=True, editable=False)),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4)),
+ ('notification_type', models.CharField(
+ choices=[('initial', 'Initial Receipt Notification'), ('reminder', 'Payment Reminder'),
+ ('overdue', 'Overdue Payment Notice')], max_length=20)),
+ ('sent_at', models.DateTimeField(blank=True, editable=False)),
+ ('is_delivered', models.BooleanField(default=False)),
+ ('history_id', models.AutoField(primary_key=True, serialize=False)),
+ ('history_date', models.DateTimeField(db_index=True)),
+ ('history_change_reason', models.CharField(max_length=100, null=True)),
+ ('history_type',
+ models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
+ ('history_user',
+ models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+',
+ to=settings.AUTH_USER_MODEL)),
+ ('receipt', models.ForeignKey(blank=True, db_constraint=False, null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING, related_name='+',
+ to='receipts.receipt')),
+ ],
+ options={
+ 'verbose_name': 'historical receipt notification',
+ 'verbose_name_plural': 'historical receipt notifications',
+ 'ordering': ('-history_date', '-history_id'),
+ 'get_latest_by': ('history_date', 'history_id'),
+ },
+ bases=(simple_history.models.HistoricalChanges, models.Model),
+ ),
+ migrations.CreateModel(
+ name='HistoricalReceipt',
+ fields=[
+ ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)),
+ ('deleted_by_cascade', models.BooleanField(default=False, editable=False)),
+ ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
+ ('updated_at', models.DateTimeField(blank=True, editable=False)),
+ ('id', models.UUIDField(db_index=True, default=uuid.uuid4)),
+ ('internal_receipt_number',
+ models.CharField(help_text='Уникальный идентификатор платёжной записи внутри учётной системы',
+ max_length=100)),
+ ('payer_full_name',
+ models.CharField(help_text='ФИО физического лица, производящего оплату', max_length=300)),
+ ('recipient_full_name',
+ models.CharField(help_text='ФИО обучающегося / лица, за которого осуществляется платёж',
+ max_length=300)),
+ ('institution_name',
+ models.CharField(help_text='Учебное учреждение, оказывающее услугу', max_length=500)),
+ ('service_name',
+ models.CharField(help_text='Наименование конкретной оплачиваемой услуги', max_length=300)),
+ ('service_category',
+ models.CharField(blank=True, help_text='Группировка или категория услуги', max_length=200)),
+ ('debt_at_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Остаток долга на первое число текущего месяца',
+ max_digits=10)),
+ ('recalculation_amount',
+ models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Изменение начислений',
+ max_digits=10)),
+ ('paid_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Сколько уже заплатили за услугу', max_digits=10)),
+ ('debt_at_next_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Остаток долга на начало следующего месяца',
+ max_digits=10)),
+ ('prepayment',
+ models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Деньги, внесенные вперед',
+ max_digits=10)),
+ ('service_amount', models.DecimalField(decimal_places=2,
+ help_text='Фактическая стоимость конкретной услуги за расчетный период',
+ max_digits=10, validators=[
+ django.core.validators.MinValueValidator(Decimal('0.00'))])),
+ ('total_amount',
+ models.DecimalField(decimal_places=2, help_text='Конечный итог суммы к оплате', max_digits=10,
+ validators=[django.core.validators.MinValueValidator(Decimal('0.00'))])),
+ ('receipt_date', models.DateField(help_text='Дата создания квитанции')),
+ ('payment_due_date', models.DateField(help_text='Крайний срок оплаты')),
+ ('payment_purpose', models.TextField(help_text='Показывает основание для оплаты')),
+ ('qr_code_data', models.TextField(blank=True,
+ help_text='QR-код данные для быстрой оплаты через банковские приложения')),
+ ('barcode_data',
+ models.TextField(blank=True, help_text='Штрихкодовая строка для автоматического считывания')),
+ ('is_viewed', models.BooleanField(default=False, help_text='Была ли просмотрена квитанция родителем')),
+ ('viewed_at', models.DateTimeField(blank=True, help_text='Время просмотра квитанции', null=True)),
+ ('pdf_file', models.TextField(blank=True, help_text='PDF файл квитанции', max_length=100, null=True)),
+ ('history_id', models.AutoField(primary_key=True, serialize=False)),
+ ('history_date', models.DateTimeField(db_index=True)),
+ ('history_change_reason', models.CharField(max_length=100, null=True)),
+ ('history_type',
+ models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
+ ('history_user',
+ models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+',
+ to=settings.AUTH_USER_MODEL)),
+ ('student_profile', models.ForeignKey(blank=True, db_constraint=False, null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING, related_name='+',
+ to='students.studentprofile')),
+ ],
+ options={
+ 'verbose_name': 'historical receipt',
+ 'verbose_name_plural': 'historical receipts',
+ 'ordering': ('-history_date', '-history_id'),
+ 'get_latest_by': ('history_date', 'history_id'),
+ },
+ bases=(simple_history.models.HistoricalChanges, models.Model),
+ ),
+ migrations.AddIndex(
+ model_name='receipt',
+ index=models.Index(fields=['student_profile', '-created_at'], name='receipts_re_student_10a417_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='receipt',
+ index=models.Index(fields=['payment_due_date'], name='receipts_re_payment_8f28a9_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='receipt',
+ index=models.Index(fields=['is_viewed'], name='receipts_re_is_view_034632_idx'),
+ ),
+ ]
diff --git a/open_schools_platform/receipt_management/receipts/migrations/0002_auto_20250524_2242.py b/open_schools_platform/receipt_management/receipts/migrations/0002_auto_20250524_2242.py
new file mode 100644
index 00000000..11e4e0b5
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/migrations/0002_auto_20250524_2242.py
@@ -0,0 +1,66 @@
+# Generated by Django 3.2.12 on 2025-05-24 17:42
+
+import uuid
+from decimal import Decimal
+
+import django.utils.timezone
+import rules.contrib.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('receipts', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='historicalreceipt',
+ name='charged_this_month',
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Сумма, начисленная за текущий месяц', max_digits=10),
+ ),
+ migrations.AddField(
+ model_name='receipt',
+ name='charged_this_month',
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Сумма, начисленная за текущий месяц', max_digits=10),
+ ),
+ migrations.CreateModel(
+ name='ReceiptService',
+ fields=[
+ ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)),
+ ('deleted_by_cascade', models.BooleanField(default=False, editable=False)),
+ ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
+ ('institution', models.CharField(help_text='Institution code/name for this service', max_length=200)),
+ ('service_name', models.CharField(help_text='Name of the specific service', max_length=300)),
+ ('debt_at_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Debt at start of month for this service',
+ max_digits=10)),
+ ('charged_this_month', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Amount charged this month for this service',
+ max_digits=10)),
+ ('recalculation_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Recalculation amount for this service',
+ max_digits=10)),
+ ('paid_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Amount paid for this service', max_digits=10)),
+ ('debt_at_next_month_start', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Debt at start of next month for this service',
+ max_digits=10)),
+ ('prepayment',
+ models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Prepayment for this service',
+ max_digits=10)),
+ ('service_amount', models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Total amount due for this service', max_digits=10)),
+ ('receipt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services',
+ to='receipts.receipt')),
+ ],
+ options={
+ 'ordering': ['service_name'],
+ },
+ bases=(rules.contrib.models.RulesModelMixin, models.Model),
+ ),
+ ]
diff --git a/open_schools_platform/receipt_management/receipts/migrations/0003_auto_20250524_2304.py b/open_schools_platform/receipt_management/receipts/migrations/0003_auto_20250524_2304.py
new file mode 100644
index 00000000..4a43caa9
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/migrations/0003_auto_20250524_2304.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.12 on 2025-05-24 18:04
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('receipts', '0002_auto_20250524_2242'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='historicalreceipt',
+ name='barcode_data',
+ ),
+ migrations.RemoveField(
+ model_name='receipt',
+ name='barcode_data',
+ ),
+ ]
diff --git a/open_schools_platform/receipt_management/receipts/migrations/0004_auto_20250526_1221.py b/open_schools_platform/receipt_management/receipts/migrations/0004_auto_20250526_1221.py
new file mode 100644
index 00000000..0b29e047
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/migrations/0004_auto_20250526_1221.py
@@ -0,0 +1,66 @@
+# Generated by Django 3.2.12 on 2025-05-26 07:21
+
+from decimal import Decimal
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('receipts', '0003_auto_20250524_2304'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='receiptservice',
+ name='charged_this_month',
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Сумма, взимаемая в этом месяце за кружок', max_digits=10),
+ ),
+ migrations.AlterField(
+ model_name='receiptservice',
+ name='debt_at_month_start',
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Долг на начало месяца для этого кружка', max_digits=10),
+ ),
+ migrations.AlterField(
+ model_name='receiptservice',
+ name='debt_at_next_month_start',
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Долг на начало следующего месяца за кружок', max_digits=10),
+ ),
+ migrations.AlterField(
+ model_name='receiptservice',
+ name='institution',
+ field=models.CharField(help_text='Учебное учреждение', max_length=200),
+ ),
+ migrations.AlterField(
+ model_name='receiptservice',
+ name='paid_amount',
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Сколько оплачено за кружок',
+ max_digits=10),
+ ),
+ migrations.AlterField(
+ model_name='receiptservice',
+ name='prepayment',
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Предоплата за кружок',
+ max_digits=10),
+ ),
+ migrations.AlterField(
+ model_name='receiptservice',
+ name='recalculation_amount',
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Перерасчет в этом месяце за кружок', max_digits=10),
+ ),
+ migrations.AlterField(
+ model_name='receiptservice',
+ name='service_amount',
+ field=models.DecimalField(decimal_places=2, default=Decimal('0.00'),
+ help_text='Общая сумма долга за кружок', max_digits=10),
+ ),
+ migrations.AlterField(
+ model_name='receiptservice',
+ name='service_name',
+ field=models.CharField(help_text='Название кружка', max_length=300),
+ ),
+ ]
diff --git a/open_schools_platform/receipt_management/receipts/migrations/__init__.py b/open_schools_platform/receipt_management/receipts/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/open_schools_platform/receipt_management/receipts/models.py b/open_schools_platform/receipt_management/receipts/models.py
new file mode 100644
index 00000000..6bcc60b7
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/models.py
@@ -0,0 +1,267 @@
+import uuid
+from decimal import Decimal
+
+from django.core.validators import MinValueValidator
+from django.db import models
+from simple_history.models import HistoricalRecords
+
+from open_schools_platform.common.models import BaseModel, BaseManager
+from open_schools_platform.student_management.students.models import StudentProfile
+
+
+class ReceiptManager(BaseManager):
+ def create_receipt(self, student_profile: StudentProfile, **kwargs):
+ receipt = self.model(
+ student_profile=student_profile,
+ **kwargs
+ )
+ receipt.full_clean()
+ receipt.save(using=self.db)
+ return receipt
+
+
+class Receipt(BaseModel):
+ """
+ Main receipt model based on the field analysis.
+ Contains all essential receipt information for the MVP.
+ """
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4)
+
+ student_profile = models.ForeignKey(
+ StudentProfile,
+ on_delete=models.CASCADE,
+ related_name="receipts"
+ )
+
+ internal_receipt_number = models.CharField(
+ max_length=100,
+ help_text="Уникальный идентификатор платёжной записи внутри учётной системы"
+ )
+
+ payer_full_name = models.CharField(
+ max_length=300,
+ help_text="ФИО физического лица, производящего оплату"
+ )
+ recipient_full_name = models.CharField(
+ max_length=300,
+ help_text="ФИО обучающегося / лица, за которого осуществляется платёж"
+ )
+
+ institution_name = models.CharField(
+ max_length=500,
+ help_text="Учебное учреждение, оказывающее услугу"
+ )
+
+ service_name = models.CharField(
+ max_length=300,
+ help_text="Наименование конкретной оплачиваемой услуги"
+ )
+ service_category = models.CharField(
+ max_length=200,
+ blank=True,
+ help_text="Группировка или категория услуги"
+ )
+
+ debt_at_month_start = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Остаток долга на первое число текущего месяца"
+ )
+ charged_this_month = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Сумма, начисленная за текущий месяц"
+ )
+ recalculation_amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Изменение начислений"
+ )
+ paid_amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Сколько уже заплатили за услугу"
+ )
+ debt_at_next_month_start = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Остаток долга на начало следующего месяца"
+ )
+ prepayment = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Деньги, внесенные вперед"
+ )
+ service_amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ validators=[MinValueValidator(Decimal('0.00'))],
+ help_text="Фактическая стоимость конкретной услуги за расчетный период"
+ )
+ total_amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ validators=[MinValueValidator(Decimal('0.00'))],
+ help_text="Конечный итог суммы к оплате"
+ )
+
+ receipt_date = models.DateField(
+ help_text="Дата создания квитанции"
+ )
+ payment_due_date = models.DateField(
+ help_text="Крайний срок оплаты"
+ )
+
+ payment_purpose = models.TextField(
+ help_text="Показывает основание для оплаты"
+ )
+ qr_code_data = models.TextField(
+ blank=True,
+ help_text="QR-код данные для быстрой оплаты через банковские приложения"
+ )
+
+ pdf_file = models.FileField(
+ upload_to='receipts/pdfs/',
+ null=True,
+ blank=True,
+ help_text="PDF файл квитанции"
+ )
+
+ history = HistoricalRecords()
+ objects = ReceiptManager()
+
+ class Meta:
+ ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['student_profile', '-created_at']),
+ models.Index(fields=['payment_due_date']),
+ ]
+
+ def __str__(self):
+ return f"Receipt {self.internal_receipt_number} for {self.recipient_full_name}"
+
+ @property
+ def is_overdue(self):
+ """Check if receipt payment is overdue"""
+ from django.utils import timezone
+ return timezone.now().date() > self.payment_due_date and self.total_amount > 0
+
+
+class ReceiptNotificationManager(BaseManager):
+ def create_notification(self, receipt: Receipt, notification_type: str):
+ notification = self.model(
+ receipt=receipt,
+ notification_type=notification_type
+ )
+ notification.full_clean()
+ notification.save(using=self.db)
+ return notification
+
+
+class ReceiptNotification(BaseModel):
+ """
+ Track notifications sent for receipts
+ """
+ NOTIFICATION_TYPES = [
+ ('initial', 'Initial Receipt Notification'),
+ ('reminder', 'Payment Reminder'),
+ ('overdue', 'Overdue Payment Notice'),
+ ]
+
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4)
+ receipt = models.ForeignKey(
+ Receipt,
+ on_delete=models.CASCADE,
+ related_name="notifications"
+ )
+ notification_type = models.CharField(
+ max_length=20,
+ choices=NOTIFICATION_TYPES
+ )
+ sent_at = models.DateTimeField(auto_now_add=True)
+ is_delivered = models.BooleanField(default=False)
+
+ objects = ReceiptNotificationManager()
+ history = HistoricalRecords()
+
+ class Meta:
+ ordering = ['-sent_at']
+
+ def __str__(self):
+ return f"{self.notification_type} notification for {self.receipt}"
+
+
+class ReceiptService(BaseModel):
+ """
+ Individual service within a receipt - for detailed breakdown
+ """
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4)
+ receipt = models.ForeignKey(
+ Receipt,
+ on_delete=models.CASCADE,
+ related_name="services"
+ )
+
+ institution = models.CharField(
+ max_length=200,
+ help_text="Учебное учреждение"
+ )
+ service_name = models.CharField(
+ max_length=300,
+ help_text="Название кружка"
+ )
+
+ debt_at_month_start = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Долг на начало месяца для этого кружка"
+ )
+ charged_this_month = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Сумма, взимаемая в этом месяце за кружок"
+ )
+ recalculation_amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Перерасчет в этом месяце за кружок"
+ )
+ paid_amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Сколько оплачено за кружок"
+ )
+ debt_at_next_month_start = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Долг на начало следующего месяца за кружок"
+ )
+ prepayment = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Предоплата за кружок"
+ )
+ service_amount = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=Decimal('0.00'),
+ help_text="Общая сумма долга за кружок"
+ )
+
+ class Meta:
+ ordering = ['service_name']
+
+ def __str__(self):
+ return f"{self.service_name} - {self.service_amount}"
diff --git a/open_schools_platform/receipt_management/receipts/selectors.py b/open_schools_platform/receipt_management/receipts/selectors.py
new file mode 100644
index 00000000..9bdbd136
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/selectors.py
@@ -0,0 +1,241 @@
+from typing import Dict, Optional
+
+from django.db.models import QuerySet
+from rest_framework.exceptions import PermissionDenied
+
+from open_schools_platform.common.selectors import selector_factory
+from open_schools_platform.parent_management.families.models import Family
+from open_schools_platform.receipt_management.receipts.filters import ReceiptFilter, ReceiptNotificationFilter
+from open_schools_platform.receipt_management.receipts.models import Receipt, ReceiptNotification
+from open_schools_platform.student_management.students.models import StudentProfile
+from open_schools_platform.user_management.users.models import User
+
+
+@selector_factory(Receipt)
+def get_receipts(*, filters=None, prefetch_related_list=None) -> QuerySet:
+ """
+ Get receipts with filtering support.
+ """
+ filters = filters or {}
+ prefetch_related_list = prefetch_related_list or []
+
+ qs = Receipt.objects.prefetch_related(*prefetch_related_list).all()
+ receipts = ReceiptFilter(filters, qs).qs
+
+ return receipts
+
+
+@selector_factory(Receipt)
+def get_receipt(*, filters=None, user: User = None, prefetch_related_list=None) -> Receipt:
+ """
+ Get a single receipt with permission checks.
+ """
+ filters = filters or {}
+ prefetch_related_list = prefetch_related_list or []
+
+ qs = Receipt.objects.prefetch_related(*prefetch_related_list).all()
+ receipt = ReceiptFilter(filters, qs).qs.first()
+
+ if user and receipt and not user.has_perm("receipts.receipt_access", receipt):
+ raise PermissionDenied
+
+ return receipt
+
+
+@selector_factory(ReceiptNotification)
+def get_receipt_notifications(*, filters=None, prefetch_related_list=None) -> QuerySet:
+ """
+ Get receipt notifications with filtering support.
+ """
+ filters = filters or {}
+ prefetch_related_list = prefetch_related_list or []
+
+ qs = ReceiptNotification.objects.prefetch_related(*prefetch_related_list).all()
+ notifications = ReceiptNotificationFilter(filters, qs).qs
+
+ return notifications.order_by('-sent_at')
+
+def get_overdue_receipts() -> QuerySet:
+ """
+ Get all overdue receipts for system-wide processing.
+ """
+ from django.utils import timezone
+
+ return Receipt.objects.filter(
+ payment_due_date__lt=timezone.now().date(),
+ total_amount__gt=0
+ ).order_by('payment_due_date')
+
+
+def get_receipts_for_family(family: Family, filters: Dict = None) -> QuerySet[Receipt]:
+ """
+ Get receipts for all students in a family
+ """
+ filters = filters or {}
+
+ student_profiles = family.student_profiles.all()
+ qs = Receipt.objects.filter(student_profile__in=student_profiles)
+
+ if filters:
+ qs = qs.filter(**filters)
+
+ return qs.order_by('-created_at')
+
+
+def get_receipts_for_student_profile(student_profile: StudentProfile, filters: Dict = None) -> QuerySet[Receipt]:
+ """
+ Get receipts for a specific student profile
+ """
+ filters = filters or {}
+
+ qs = Receipt.objects.filter(student_profile=student_profile)
+
+ if filters:
+ qs = qs.filter(**filters)
+
+ return qs.order_by('-created_at')
+
+
+def get_overdue_receipts_for_family(family: Family) -> QuerySet[Receipt]:
+ """
+ Get overdue receipts for a family
+ """
+ from django.utils import timezone
+ today = timezone.now().date()
+
+ return get_receipts_for_family(family, {
+ 'payment_due_date__lt': today,
+ 'total_amount__gt': 0
+ })
+
+
+def get_recent_receipts_for_family(family: Family, days: int = 30) -> QuerySet[Receipt]:
+ """
+ Get recent receipts for a family
+ """
+ from django.utils import timezone
+ from datetime import timedelta
+
+ cutoff_date = timezone.now() - timedelta(days=days)
+
+ return get_receipts_for_family(family, {
+ 'created_at__gte': cutoff_date
+ })
+
+
+@selector_factory(Receipt)
+def get_receipts_with_filters(user: User, filters: Dict = None, prefetch_related_list=None) -> QuerySet:
+ filters = filters or {}
+ prefetch_related_list = prefetch_related_list or []
+
+ qs = Receipt.objects.prefetch_related(*prefetch_related_list).all()
+
+ receipts = ReceiptFilter(filters, qs).qs
+
+ return receipts.order_by('-created_at')
+
+
+def get_single_receipt_notification(*, filters: Dict = None, empty_exception=False) -> Optional[ReceiptNotification]:
+ """
+ Get single receipt notification by filters
+ """
+ filters = filters or {}
+
+ try:
+ return ReceiptNotification.objects.get(**filters)
+ except ReceiptNotification.DoesNotExist:
+ if empty_exception:
+ from open_schools_platform.errors.exceptions import NotFound
+ raise NotFound("Receipt notification not found")
+ return None
+
+
+def get_notifications_list(*, filters: Dict = None) -> QuerySet[ReceiptNotification]:
+ """
+ Get receipt notifications by filters
+ """
+ filters = filters or {}
+
+ qs = ReceiptNotification.objects.all()
+
+ if filters:
+ qs = qs.filter(**filters)
+
+ return qs.order_by('-sent_at')
+
+
+def get_notifications_for_receipt(receipt: Receipt) -> QuerySet[ReceiptNotification]:
+ """
+ Get all notifications for a specific receipt
+ """
+ return ReceiptNotification.objects.filter(receipt=receipt).order_by('-sent_at')
+
+
+def get_recent_notifications(days: int = 7) -> QuerySet[ReceiptNotification]:
+ """
+ Get notifications from the last N days
+ """
+ from django.utils import timezone
+ from datetime import timedelta
+
+ cutoff_date = timezone.now() - timedelta(days=days)
+
+ return ReceiptNotification.objects.filter(
+ sent_at__gte=cutoff_date
+ ).order_by('-sent_at')
+
+
+def get_failed_notifications() -> QuerySet[ReceiptNotification]:
+ """
+ Get notifications that failed to deliver
+ """
+ return ReceiptNotification.objects.filter(is_delivered=False)
+
+
+def get_receipt_statistics_for_family(family: Family) -> Dict:
+ """
+ Get receipt statistics for a family
+ """
+ receipts = get_receipts_for_family(family)
+ total_receipts = receipts.count()
+ overdue_receipts = get_overdue_receipts_for_family(family).count()
+
+ total_amount = sum(receipt.total_amount for receipt in receipts)
+ overdue_amount = sum(receipt.total_amount for receipt in get_overdue_receipts_for_family(family))
+
+ return {
+ 'total_receipts': total_receipts,
+ 'overdue_receipts': overdue_receipts,
+ 'total_amount': total_amount,
+ 'overdue_amount': overdue_amount,
+ }
+
+
+def get_receipt_statistics_for_student(student_profile: StudentProfile) -> Dict:
+ """
+ Get receipt statistics for a student
+ """
+ receipts = get_receipts_for_student_profile(student_profile)
+ total_receipts = receipts.count()
+
+ from django.utils import timezone
+ today = timezone.now().date()
+ overdue_receipts = receipts.filter(
+ payment_due_date__lt=today,
+ total_amount__gt=0
+ ).count()
+
+ total_amount = sum(receipt.total_amount for receipt in receipts)
+ overdue_amount = sum(
+ receipt.total_amount for receipt in receipts.filter(
+ payment_due_date__lt=today,
+ total_amount__gt=0
+ )
+ )
+
+ return {
+ 'total_receipts': total_receipts,
+ 'overdue_receipts': overdue_receipts,
+ 'total_amount': total_amount,
+ 'overdue_amount': overdue_amount,
+ }
diff --git a/open_schools_platform/receipt_management/receipts/serializers.py b/open_schools_platform/receipt_management/receipts/serializers.py
new file mode 100644
index 00000000..acd12b22
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/serializers.py
@@ -0,0 +1,182 @@
+from django.utils import timezone
+from rest_framework import serializers
+
+from open_schools_platform.common.serializers import BaseModelSerializer
+from open_schools_platform.receipt_management.receipts.models import Receipt, ReceiptNotification, ReceiptService
+from open_schools_platform.student_management.students.serializers import GetStudentProfileSerializer
+
+
+class ReceiptServiceSerializer(BaseModelSerializer):
+ """Serializer for individual receipt services"""
+
+ class Meta:
+ model = ReceiptService
+ fields = [
+ 'id', 'institution', 'service_name', 'debt_at_month_start',
+ 'charged_this_month', 'recalculation_amount', 'paid_amount',
+ 'debt_at_next_month_start', 'prepayment', 'service_amount'
+ ]
+
+
+class CreateReceiptSerializer(BaseModelSerializer):
+ """Serializer for creating receipts - used by dispatchers"""
+
+ class Meta:
+ model = Receipt
+ fields = [
+ 'student_profile', 'internal_receipt_number', 'payer_full_name',
+ 'recipient_full_name', 'institution_name', 'service_name',
+ 'service_category', 'debt_at_month_start', 'charged_this_month', 'recalculation_amount',
+ 'paid_amount', 'debt_at_next_month_start', 'prepayment',
+ 'service_amount', 'total_amount', 'receipt_date', 'payment_due_date',
+ 'payment_purpose', 'qr_code_data', 'pdf_file'
+ ]
+
+
+class UpdateReceiptSerializer(serializers.Serializer):
+ """Serializer for updating receipts"""
+
+ internal_receipt_number = serializers.CharField(max_length=100, required=False)
+ payer_full_name = serializers.CharField(max_length=300, required=False)
+ recipient_full_name = serializers.CharField(max_length=300, required=False)
+ institution_name = serializers.CharField(max_length=500, required=False)
+ service_name = serializers.CharField(max_length=300, required=False)
+ service_category = serializers.CharField(max_length=200, required=False, allow_blank=True)
+ debt_at_month_start = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
+ charged_this_month = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
+ recalculation_amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
+ paid_amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
+ debt_at_next_month_start = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
+ prepayment = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
+ service_amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
+ total_amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
+ receipt_date = serializers.DateField(required=False)
+ payment_due_date = serializers.DateField(required=False)
+ payment_purpose = serializers.CharField(required=False)
+ qr_code_data = serializers.CharField(required=False, allow_blank=True)
+ pdf_file = serializers.FileField(required=False)
+
+
+class GetReceiptSerializer(BaseModelSerializer):
+ """Serializer for getting receipt details - used by parents mobile app"""
+
+ student_profile = GetStudentProfileSerializer(read_only=True)
+ is_overdue = serializers.ReadOnlyField()
+ services = ReceiptServiceSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = Receipt
+ fields = [
+ 'id', 'student_profile', 'internal_receipt_number', 'payer_full_name', 'recipient_full_name',
+ 'institution_name', 'service_name',
+ 'service_category', 'service_amount', 'total_amount', 'receipt_date',
+ 'payment_due_date', 'payment_purpose', 'qr_code_data',
+ 'is_overdue', 'pdf_file', 'services', 'created_at'
+ ]
+
+
+class GetReceiptListSerializer(BaseModelSerializer):
+ """Serializer for receipt list - simplified view for mobile app"""
+
+ is_overdue = serializers.ReadOnlyField()
+
+ class Meta:
+ model = Receipt
+ fields = [
+ 'id', 'recipient_full_name', 'payer_full_name', 'institution_name', 'service_name',
+ 'total_amount', 'receipt_date', 'payment_due_date',
+ 'is_overdue', 'created_at'
+ ]
+
+
+class GetReceiptDetailedSerializer(BaseModelSerializer):
+ """Serializer for detailed receipt view - used by dispatchers"""
+
+ student_profile = GetStudentProfileSerializer(read_only=True)
+ is_overdue = serializers.ReadOnlyField()
+ notifications_count = serializers.SerializerMethodField()
+ services = ReceiptServiceSerializer(many=True, read_only=True)
+
+ def get_notifications_count(self, obj):
+ return obj.notifications.count()
+
+ class Meta:
+ model = Receipt
+ fields = [
+ 'id', 'student_profile', 'internal_receipt_number', 'payer_full_name',
+ 'recipient_full_name', 'institution_name', 'service_name', 'service_category', 'debt_at_month_start',
+ 'charged_this_month', 'recalculation_amount',
+ 'paid_amount', 'debt_at_next_month_start', 'prepayment',
+ 'service_amount', 'total_amount', 'receipt_date', 'payment_due_date',
+ 'payment_purpose', 'qr_code_data',
+ 'is_overdue', 'pdf_file', 'notifications_count', 'services', 'created_at']
+
+
+class UploadReceiptPDFSerializer(serializers.Serializer):
+ """Serializer for PDF file upload and parsing"""
+
+ pdf_file = serializers.FileField()
+ student_profile_id = serializers.UUIDField(required=False)
+
+ def validate_pdf_file(self, value):
+ if not value.name.endswith('.pdf'):
+ raise serializers.ValidationError("File must be a PDF")
+
+ # Check file size (max 10MB)
+ if value.size > 10 * 1024 * 1024:
+ raise serializers.ValidationError("File size must be less than 10MB")
+
+ return value
+
+
+class BulkReceiptUploadSerializer(serializers.Serializer):
+ """Serializer for bulk receipt upload"""
+
+ pdf_files = serializers.ListField(
+ child=serializers.FileField(),
+ min_length=1,
+ max_length=50
+ )
+
+ def validate_pdf_files(self, value):
+ for file in value:
+ if not file.name.endswith('.pdf'):
+ raise serializers.ValidationError(f"File {file.name} must be a PDF")
+
+ if file.size > 10 * 1024 * 1024:
+ raise serializers.ValidationError(f"File {file.name} size must be less than 10MB")
+
+ return value
+
+
+class SendReceiptNotificationSerializer(serializers.Serializer):
+ """Serializer for sending receipt notifications"""
+
+ receipt_ids = serializers.ListField(
+ child=serializers.UUIDField(),
+ min_length=1
+ )
+ notification_type = serializers.ChoiceField(
+ choices=ReceiptNotification.NOTIFICATION_TYPES,
+ default='initial'
+ )
+ custom_message = serializers.CharField(
+ max_length=500,
+ required=False,
+ allow_blank=True,
+ help_text="Custom message to include in notification"
+ )
+
+
+class GetReceiptNotificationSerializer(BaseModelSerializer):
+ """Serializer for receipt notifications"""
+
+ receipt_id = serializers.UUIDField(source='receipt.id')
+ receipt_number = serializers.CharField(source='receipt.internal_receipt_number')
+
+ class Meta:
+ model = ReceiptNotification
+ fields = [
+ 'id', 'receipt_id', 'receipt_number', 'notification_type',
+ 'sent_at', 'is_delivered'
+ ]
diff --git a/open_schools_platform/receipt_management/receipts/services.py b/open_schools_platform/receipt_management/receipts/services.py
new file mode 100644
index 00000000..fae9de09
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/services.py
@@ -0,0 +1,696 @@
+import io
+import logging
+import re
+from datetime import date, datetime
+from decimal import Decimal, InvalidOperation
+from typing import Dict, List, Tuple
+
+import pdfplumber
+from dateutil.relativedelta import relativedelta
+from django.core.files.base import ContentFile
+from django.http import HttpResponse, Http404
+from pypdf import PdfWriter, PdfReader
+
+from open_schools_platform.receipt_management.receipts.selectors import get_receipts_for_family
+from open_schools_platform.receipt_management.receipts.models import Receipt, ReceiptService
+from open_schools_platform.student_management.students.models import StudentProfile
+
+logger = logging.getLogger(__name__)
+
+PATTERNS = {
+ 'internal_receipt_number': r'(?:Лицевой счет)[\s:]*(\d+)',
+ 'payer_full_name': r'(?:Плательщик)[\s:]*([А-Яа-яЁё\s]+?)(?:\s+Группа)',
+ 'recipient_full_name': r'(?:За кого)[\s:]*([А-Яа-яЁё\s]+)(?=\s*Счет от)',
+ 'institution_name': r'(Департамент[^)]*[)])',
+ 'service_category': r'(?:Группа)[\s:]*([А-ЯА-я\s\d]+?)(?=\s*Наименование платежа)',
+ 'payment_due_date': r'(?:Оплатить до)[\s:]*(\d{1,2}\.\d{1,2}\.\d{4})',
+ 'payment_purpose': r'(?:Наименование платежа)[\s:]*([^З]+?)(?=\s*За кого)',
+ 'receipt_date': r'(?:Счет от)[\s:]*(\d{1,2}\.\d{1,2}\.\d{4})',
+}
+
+MONTHS_RU = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
+ 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']
+
+
+def clean_row(row):
+ cleaned = []
+ for cell in row:
+ if cell is None:
+ cleaned.append(cell)
+ else:
+ cell_str = str(cell).replace('\n', '').strip()
+ if re.match(r'^\d+[ ]*\d*,\d{2}$', cell_str):
+ cell_str = cell_str.replace(' ', '').replace(',', '.')
+ cleaned.append(cell_str)
+ return cleaned
+
+
+def create_formatted_headers(text: str) -> List[str]:
+ receipt_date_match = re.search(PATTERNS['receipt_date'], text)
+ if not receipt_date_match:
+ return []
+
+ receipt_date = datetime.strptime(receipt_date_match.group(1), '%d.%m.%Y').date()
+ first_day_current = receipt_date.replace(day=1)
+ first_day_next = (receipt_date + relativedelta(months=1)).replace(day=1)
+
+ month_name = MONTHS_RU[receipt_date.month - 1]
+ year = receipt_date.year
+
+ return [
+ 'Учреждение',
+ 'Вид услуги',
+ f'Задолженность на {first_day_current.strftime("%d.%m.%Y")}',
+ f'Начислено за {month_name} {year}',
+ 'Перерасчет за предыдущие периоды',
+ f'Оплачено {month_name} {year}',
+ f'Задолженность на {first_day_next.strftime("%d.%m.%Y")}',
+ 'Предоплата',
+ 'Итого к оплате в рублях'
+ ]
+
+
+def extract_logical_receipts_from_pdf(pdf_file) -> List[Dict]:
+ """
+ Extract logical receipts from PDF, handling both single and multi-receipt pages.
+ Returns a list of dictionaries, each representing a logical receipt.
+ """
+ table_settings = {
+ "vertical_strategy": "lines",
+ "horizontal_strategy": "lines",
+ "intersection_x_tolerance": 4,
+ }
+
+ pdf_file.seek(0)
+ logical_receipts = []
+
+ with pdfplumber.open(pdf_file) as pdf:
+ for page_num, page in enumerate(pdf.pages):
+
+ tables = page.extract_tables(table_settings=table_settings)
+ logger.debug(f"Page {page_num + 1}: Found {len(tables)} tables")
+
+ needs_horizontal_split = len(tables) >= 4
+ if needs_horizontal_split:
+ page_width = page.width
+ page_height = page.height
+ mid_y = page_height / 2
+
+ regions_definitions = [
+ {"name": "Upper Half", "bbox": (0, 0, page_width, mid_y)},
+ {"name": "Lower Half", "bbox": (0, mid_y, page_width, page_height)}
+ ]
+
+ for region_def in regions_definitions:
+ cropped_page = page.crop(region_def["bbox"])
+ region_text = cropped_page.extract_text() or ""
+ region_raw_tables = cropped_page.extract_tables(table_settings)
+
+ processed_region_table = []
+ if region_raw_tables and region_raw_tables[0]:
+ first_raw_table = region_raw_tables[0]
+ temp_clean_table = []
+ for row in first_raw_table:
+ if (sum(1 for cell in row if cell is not None) >= 3 and
+ not any('Учреждение' in str(cell or '') for cell in row)):
+ temp_clean_table.append(clean_row(row))
+
+ if temp_clean_table:
+ table_headers = create_formatted_headers(region_text)
+ if table_headers:
+ processed_region_table.append(table_headers)
+ processed_region_table.extend(temp_clean_table)
+
+ logical_receipts.append({
+ 'text': region_text,
+ 'processed_table': processed_region_table,
+ 'source_page_number': page_num + 1,
+ 'source_region_name': region_def["name"]
+ })
+
+ else:
+ page_text = page.extract_text() or ""
+ clean_table = []
+
+ if tables:
+ first_table = tables[0]
+ temp_clean_table = []
+ for row in first_table:
+ if (sum(1 for cell in row if cell is not None) >= 3 and
+ not any('Учреждение' in str(cell or '') for cell in row)):
+ temp_clean_table.append(clean_row(row))
+
+ if temp_clean_table:
+ headers = create_formatted_headers(page_text)
+ if headers:
+ clean_table.append(headers)
+ clean_table.extend(temp_clean_table)
+
+ logical_receipts.append({
+ 'text': page_text,
+ 'processed_table': clean_table,
+ 'source_page_number': page_num + 1,
+ 'source_region_name': "Full Page"
+ })
+
+ logger.info(f"Total logical receipts found: {len(logical_receipts)}")
+ return logical_receipts
+
+def parse_amount(amount_str: str) -> Decimal:
+ if not amount_str or amount_str == '0.00':
+ return Decimal('0.00')
+
+ try:
+ return Decimal(str(amount_str))
+ except (ValueError, InvalidOperation):
+ return Decimal('0.00')
+
+
+class PDFReceiptParser:
+
+ def __init__(self):
+ self.patterns = {
+ 'internal_receipt_number': r'(?:Лицевой счет)[\s:]*(\d+)',
+ 'payer_full_name': r'(?:Плательщик)[\s:]*([А-Яа-яЁё\s]+?)(?:\s+Группа)',
+ 'recipient_full_name': r'(?:За кого)[\s:]*([А-Яа-яЁё\s]+)(?=\s*Счет от)',
+ 'institution_name': r'(Департамент[^)]*[)])',
+ 'service_category': r'(?:Группа)[\s:]*([А-ЯА-я\s\d]+?)(?=\s*Наименование платежа)',
+ 'payment_due_date': r'(?:Оплатить до)[\s:]*(\d{1,2}\.\d{1,2}\.\d{4})',
+ 'payment_purpose': r'(?:Наименование платежа)[\s:]*([^З]+?)(?=\s*За кого)',
+ 'receipt_date': r'(?:Счет от)[\s:]*(\d{1,2}\.\d{1,2}\.\d{4})',
+ }
+
+ def extract_financial_data_from_table(self, tables: List) -> Dict:
+ """Extract and sum financial data from table rows"""
+ financial_data = {
+ 'debt_at_month_start': Decimal('0.00'),
+ 'charged_this_month': Decimal('0.00'),
+ 'recalculation_amount': Decimal('0.00'),
+ 'paid_amount': Decimal('0.00'),
+ 'debt_at_next_month_start': Decimal('0.00'),
+ 'prepayment': Decimal('0.00'),
+ 'service_amount': Decimal('0.00'),
+ 'services': []
+ }
+ if not tables or len(tables) < 2:
+ return financial_data
+
+ for row in tables[1:]:
+ if not row or len(row) < 9:
+ continue
+
+ service_info = {
+ 'institution': row[0],
+ 'service_name': row[1],
+ 'debt_at_month_start': parse_amount(row[2]),
+ 'charged_this_month': parse_amount(row[3]),
+ 'recalculation_amount': parse_amount(row[4]),
+ 'paid_amount': parse_amount(row[5]),
+ 'debt_at_next_month_start': parse_amount(row[6]),
+ 'prepayment': parse_amount(row[7]),
+ 'service_amount': parse_amount(row[8])
+ }
+
+
+ financial_data['services'].append(service_info)
+
+ financial_data['debt_at_month_start'] += service_info['debt_at_month_start']
+ financial_data['charged_this_month'] += service_info['charged_this_month']
+ financial_data['recalculation_amount'] += service_info['recalculation_amount']
+ financial_data['paid_amount'] += service_info['paid_amount']
+ financial_data['debt_at_next_month_start'] += service_info['debt_at_next_month_start']
+ financial_data['prepayment'] += service_info['prepayment']
+ financial_data['service_amount'] += service_info['service_amount']
+
+ return financial_data
+
+ def extract_receipt_data(self, text, tables):
+ extracted_data = {}
+
+ for field, pattern in self.patterns.items():
+ match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE)
+ if match:
+ try:
+ value = match.group(1).strip()
+
+ if field in ['receipt_date', 'payment_due_date']:
+ value = datetime.strptime(value, '%d.%m.%Y')
+ value = value.strftime('%Y-%m-%d')
+
+ extracted_data[field] = value
+
+ except IndexError:
+ value = match.group(0).strip()
+ extracted_data[field] = value
+
+ financial_data = self.extract_financial_data_from_table(tables)
+ extracted_data.update(financial_data)
+
+ extracted_data['service_amount'] = financial_data['service_amount']
+ extracted_data['total_amount'] = financial_data['service_amount']
+
+ service_names = [s['service_name'] for s in financial_data['services'] if s['service_name']]
+ extracted_data['service_name'] = ', '.join(service_names) if service_names else "Образовательные услуги"
+
+ return extracted_data
+
+ def parse_pdf_receipt(self, pdf_file, student_profile: StudentProfile = None) -> List[Dict]:
+ """
+ Main method to parse PDF receipt and return structured data for all logical receipts.
+ Returns a list of receipt data dictionaries.
+ """
+ try:
+ logical_receipts = extract_logical_receipts_from_pdf(pdf_file)
+
+ logger.info(f"Found {len(logical_receipts)} logical receipts")
+
+ parsed_receipts = []
+
+ for logical_receipt in logical_receipts:
+ receipt_data = self.extract_receipt_data(
+ logical_receipt['text'],
+ logical_receipt['processed_table']
+ )
+ receipt_data['source_page_number'] = logical_receipt['source_page_number']
+ receipt_data['source_region_name'] = logical_receipt['source_region_name']
+
+ logger.info(
+ f"Extracted receipt data for {logical_receipt['source_region_name']} on page {logical_receipt['source_page_number']}: {receipt_data}")
+
+ if student_profile and isinstance(student_profile, StudentProfile):
+ receipt_data.setdefault('student_profile', student_profile)
+ receipt_data.setdefault('recipient_full_name', student_profile.name)
+
+ receipt_data.setdefault('internal_receipt_number', f"REC-{datetime.now().strftime('%Y%m%d%H%M%S%f')}")
+ receipt_data.setdefault('institution_name', "N/A")
+ receipt_data.setdefault('service_name', "N/A")
+ receipt_data.setdefault('service_amount', Decimal('0.00'))
+ receipt_data.setdefault('total_amount', Decimal('0.00'))
+ receipt_data.setdefault('receipt_date', date.today())
+ receipt_data.setdefault('payment_due_date', date.today())
+ receipt_data.setdefault('payment_purpose', "N/A")
+ receipt_data.setdefault('service_category', "N/A")
+
+ parsed_receipts.append(receipt_data)
+
+ return parsed_receipts
+
+ except Exception as e:
+ logger.error(f"Error parsing PDF receipt: {str(e)}")
+ raise ValueError(f"Failed to parse PDF receipt: {str(e)}")
+
+
+def process_pdf_receipt(pdf_file, student_profile: StudentProfile) -> Receipt:
+ """
+ Process uploaded PDF file and create receipt, processes only first recipe.
+ Used for legacy compatibility - expects StudentProfile instance
+ """
+ result = process_pdf_receipts_bulk(pdf_file, student_profile.id if student_profile else None)
+ return result["created_receipts"][0] if result["created_receipts"] else None
+
+
+def process_pdf_receipts_bulk(pdf_file, student_profile_id=None) -> Dict:
+ """
+ Process uploaded PDF file and create receipts for all logical receipts found
+ Args:
+ pdf_file: The PDF file to process
+ student_profile_id: StudentProfile ID (UUID string) or None
+ Returns:
+ Dict with:
+ - created_receipts: List[Receipt] - Successfully created receipts
+ - failed_details: List[Dict] - Details about failed receipt creations
+ - created_count: int - Number of successfully created receipts
+ - failed_count: int - Number of failed receipt creations
+ """
+ parser = PDFReceiptParser()
+
+ actual_student_profile = None
+ if student_profile_id is not None:
+ try:
+ actual_student_profile = StudentProfile.objects.get(id=student_profile_id)
+ logger.info(f"Found StudentProfile {actual_student_profile.id}")
+ except StudentProfile.DoesNotExist:
+ logger.warning(f"No StudentProfile found for ID {student_profile_id}")
+ actual_student_profile = None
+ except Exception as e:
+ logger.error(f"Error getting StudentProfile for ID {student_profile_id}: {str(e)}")
+ actual_student_profile = None
+
+ receipt_data_list = parser.parse_pdf_receipt(pdf_file, actual_student_profile)
+
+ created_receipts = []
+ failed_details = []
+
+ for i, receipt_data in enumerate(receipt_data_list):
+ source_page_number = receipt_data.get('source_page_number', 1)
+ source_region_name = receipt_data.get('source_region_name', 'Unknown')
+
+ try:
+ pdf_file.seek(0)
+ pdf_reader = PdfReader(pdf_file)
+ pdf_writer = PdfWriter()
+
+ page_index = source_page_number - 1
+ if page_index < len(pdf_reader.pages):
+ pdf_writer.add_page(pdf_reader.pages[page_index])
+
+ page_pdf_buffer = io.BytesIO()
+ pdf_writer.write(page_pdf_buffer)
+ page_pdf_buffer.seek(0)
+
+ pdf_content = ContentFile(
+ page_pdf_buffer.read(),
+ name=f"receipt_p{source_page_number}_{source_region_name.replace(' ', '_').lower()}.pdf"
+ )
+ receipt_data['pdf_file'] = pdf_content
+
+ else:
+ logger.warning(f"Page {source_page_number} not found in PDF, using entire PDF as fallback")
+ pdf_file.seek(0)
+ pdf_content = ContentFile(pdf_file.read())
+ receipt_data['pdf_file'] = pdf_content
+
+ except Exception as pdf_error:
+ logger.warning(f"Error extracting page {source_page_number}: {str(pdf_error)}, using entire PDF as fallback")
+ pdf_file.seek(0)
+ pdf_content = ContentFile(pdf_file.read())
+ receipt_data['pdf_file'] = pdf_content
+
+ receipt_data.pop('source_page_number', None)
+ receipt_data.pop('source_region_name', None)
+
+ if actual_student_profile:
+ receipt_data['student_profile'] = actual_student_profile
+ elif 'student_profile' in receipt_data:
+ receipt_data.pop('student_profile', None)
+
+ try:
+ receipt = create_receipt(**receipt_data)
+ created_receipts.append(receipt)
+ logger.info(
+ f"Processed PDF receipt {receipt.id} for student {receipt_data.get('recipient_full_name', 'Unknown')} from page {source_page_number} ({source_region_name})")
+
+ except Exception as e:
+ error_detail = {
+ "index": i,
+ "page": source_page_number,
+ "region": source_region_name,
+ "recipient_name": receipt_data.get('recipient_full_name', 'Unknown'),
+ "error": str(e)
+ }
+ failed_details.append(error_detail)
+ logger.error(f"Failed to create receipt from page {source_page_number} ({source_region_name}): {str(e)}")
+ continue
+
+ return {
+ "created_receipts": created_receipts,
+ "failed_details": failed_details,
+ "created_count": len(created_receipts),
+ "failed_count": len(failed_details)
+ }
+
+
+def create_receipt(student_profile: StudentProfile, **receipt_data) -> Receipt:
+ """
+ Create a new receipt and associated services
+ """
+ services_data = receipt_data.pop('services', [])
+
+ if 'total_amount' not in receipt_data and 'service_amount' in receipt_data:
+ receipt_data['total_amount'] = receipt_data['service_amount']
+
+ receipt = Receipt.objects.create_receipt(
+ student_profile=student_profile,
+ **receipt_data
+ )
+
+ for service_data in services_data:
+ ReceiptService.objects.create(
+ receipt=receipt,
+ **service_data
+ )
+
+ logger.info(f"Created receipt {receipt.id} with {len(services_data)} services for student {student_profile.name}")
+ return receipt
+
+
+class PDFDownloadService:
+ """
+ Service for handling PDF download operations for receipts
+ """
+
+ def download_single_receipt_pdf(self, receipt: Receipt) -> HttpResponse:
+ """
+ Download PDF for a single receipt
+
+ Args:
+ receipt: Receipt instance
+
+ Returns:
+ HttpResponse with PDF content
+
+ Raises:
+ Http404: If PDF file not found
+ """
+ if not receipt.pdf_file:
+ raise Http404("PDF file not found for this receipt.")
+
+ try:
+ response = HttpResponse(receipt.pdf_file.read(), content_type='application/pdf')
+ response['Content-Disposition'] = f'attachment; filename="receipt_{receipt.internal_receipt_number}.pdf"'
+ logger.info(f"Downloaded PDF for receipt {receipt.id}")
+ return response
+ except FileNotFoundError:
+ logger.error(f"PDF file not found on storage for receipt {receipt.id}")
+ raise Http404("PDF file not found on storage.")
+
+ def download_consolidated_family_pdf(self, family, filters=None) -> HttpResponse:
+ """
+ Download consolidated PDF for all receipts in a family
+ Args:
+ family: Family instance
+ filters: Optional filters for receipts
+ Returns:
+ HttpResponse with consolidated PDF content
+ Raises:
+ Http404: If no receipts or valid PDFs found
+ """
+ receipts = get_receipts_for_family(family=family, filters=filters or {})
+ if not receipts.exists():
+ raise Http404("No receipts found for this family.")
+ merger = PdfWriter()
+ processed_count = 0
+ for receipt in receipts:
+ if receipt.pdf_file:
+ try:
+ pdf_file_buffer = io.BytesIO(receipt.pdf_file.read())
+ reader = PdfReader(pdf_file_buffer)
+ for page in reader.pages:
+ merger.add_page(page)
+ receipt.pdf_file.seek(0)
+ processed_count += 1
+ logger.debug(f"Added PDF for receipt {receipt.id} to consolidated file")
+ except FileNotFoundError:
+ logger.warning(f"PDF for receipt {receipt.id} not found on storage. Skipping.")
+ continue
+ except Exception as e:
+ logger.warning(f"Error processing PDF for receipt {receipt.id}: {e}. Skipping.")
+ continue
+ if not merger.pages:
+ raise Http404("No valid PDF files found to merge for this family's receipts.")
+ output_buffer = io.BytesIO()
+ merger.write(output_buffer)
+ output_buffer.seek(0)
+ response = HttpResponse(output_buffer.read(), content_type='application/pdf')
+ response['Content-Disposition'] = f'attachment; filename="family_{family.id}_receipts.pdf"'
+ logger.info(f"Generated consolidated PDF for family {family.id} with {processed_count} receipts")
+ return response
+
+
+class ReceiptUpdateService:
+ """
+ Service for updating receipt records
+ """
+
+ def __init__(self):
+ self.logger = logging.getLogger(__name__)
+
+ def update_receipt(self, receipt: Receipt, **receipt_data) -> Receipt:
+ """
+ Update an existing receipt
+
+ Args:
+ receipt: Receipt instance to update
+ **receipt_data: Fields to update
+
+ Returns:
+ Updated Receipt instance
+ """
+ from open_schools_platform.common.services import model_update
+
+ fields = [
+ 'internal_receipt_number', 'payer_full_name', 'recipient_full_name',
+ 'institution_name', 'service_name', 'service_category',
+ 'debt_at_month_start', 'recalculation_amount', 'paid_amount',
+ 'debt_at_next_month_start', 'prepayment', 'service_amount',
+ 'total_amount', 'receipt_date', 'payment_due_date', 'payment_purpose',
+ 'qr_code_data', 'pdf_file'
+ ]
+
+ updated_receipt = model_update(
+ instance=receipt,
+ fields=fields,
+ data=receipt_data
+ )
+ self.logger.info(f"Updated receipt {receipt.id}")
+ return updated_receipt
+
+
+class ReceiptNotificationService:
+ """
+ Service for handling receipt notifications
+ """
+
+ def __init__(self):
+ self.logger = logging.getLogger(__name__)
+
+ def send_receipt_notification(self, receipt: Receipt, notification_type: str = 'initial',
+ custom_message: str = '') -> bool:
+ """
+ Send notification to parents about receipt
+
+ Args:
+ receipt: Receipt instance
+ notification_type: Type of notification ('initial', 'reminder', 'overdue')
+ custom_message: Optional custom message to append
+
+ Returns:
+ True if notification sent successfully, False otherwise
+ """
+ try:
+ from open_schools_platform.receipt_management.receipts.models import ReceiptNotification
+ from open_schools_platform.user_management.users.services import notify_user
+ from django.utils import timezone
+
+ families = receipt.student_profile.families.all()
+ parent_profiles = []
+
+ for family in families:
+ parent_profiles.extend(family.parent_profiles.all())
+
+ if not parent_profiles:
+ self.logger.warning(f"No parent profiles found for receipt {receipt.id}")
+ return False
+
+ notification = ReceiptNotification.objects.create_notification(
+ receipt=receipt,
+ notification_type=notification_type
+ )
+
+ title = self._get_notification_title(notification_type)
+ body = self._get_notification_body(receipt, notification_type, custom_message)
+
+ successful_sends = 0
+ for parent_profile in parent_profiles:
+ if hasattr(parent_profile.user, 'firebase_token'):
+ success = notify_user(
+ user=parent_profile.user,
+ title=title,
+ body=body,
+ data={'receipt_id': str(receipt.id)}
+ )
+ if success:
+ successful_sends += 1
+
+ if successful_sends > 0:
+ notification.is_sent = True
+ notification.sent_at = timezone.now()
+ notification.save()
+
+ self.logger.info(f"Sent receipt notification {notification.id} to {successful_sends} parents")
+ return successful_sends > 0
+
+ except Exception as e:
+ self.logger.error(f"Error sending receipt notification: {str(e)}")
+ return False
+
+ def bulk_send_receipt_notifications(self, receipt_ids: List[str], notification_type: str = 'initial',
+ custom_message: str = '') -> Dict[str, int]:
+ """
+ Send notifications for multiple receipts
+
+ Args:
+ receipt_ids: List of receipt IDs
+ notification_type: Type of notification
+ custom_message: Optional custom message
+
+ Returns:
+ Dictionary with success and failed counts
+ """
+ results = {'success': 0, 'failed': 0}
+
+ for receipt_id in receipt_ids:
+ try:
+ receipt = Receipt.objects.get(id=receipt_id)
+ success = self.send_receipt_notification(receipt, notification_type, custom_message)
+ if success:
+ results['success'] += 1
+ else:
+ results['failed'] += 1
+
+ except Receipt.DoesNotExist:
+ self.logger.error(f"Receipt {receipt_id} not found")
+ results['failed'] += 1
+ except Exception as e:
+ self.logger.error(f"Error sending notification for receipt {receipt_id}: {str(e)}")
+ results['failed'] += 1
+
+ self.logger.info(f"Bulk notification results: {results['success']} successful, {results['failed']} failed")
+ return results
+
+ def _get_notification_title(self, notification_type: str) -> str:
+ """
+ Get notification title based on type
+ """
+ titles = {
+ 'initial': 'Новая квитанция',
+ 'reminder': 'Напоминание об оплате',
+ 'overdue': 'Просрочен платёж'
+ }
+ return titles.get(notification_type, 'Уведомление о платеже')
+
+ def _get_notification_body(self, receipt: Receipt, notification_type: str, custom_message: str = '') -> str:
+ """
+ Get notification body text
+ """
+ base_messages = {
+ 'initial': f'Получена новая квитанция на сумму {receipt.total_amount} руб. для {receipt.recipient_full_name}',
+ 'reminder': f'Напоминаем об оплате квитанции на сумму {receipt.total_amount} руб. до {receipt.payment_due_date}',
+ 'overdue': f'Просрочен платёж по квитанции на сумму {receipt.total_amount} руб. для {receipt.recipient_full_name}'
+ }
+
+ body = base_messages.get(notification_type, f'Квитанция на сумму {receipt.total_amount} руб.')
+
+ if custom_message:
+ body += f"\n\n{custom_message}"
+
+ return body
+
+
+def update_receipt(receipt: Receipt, **receipt_data) -> Receipt:
+ service = ReceiptUpdateService()
+ return service.update_receipt(receipt, **receipt_data)
+
+
+def send_receipt_notification(receipt: Receipt, notification_type: str = 'initial',
+ custom_message: str = '') -> bool:
+ """Sends receipt notifications"""
+ service = ReceiptNotificationService()
+ return service.send_receipt_notification(receipt, notification_type, custom_message)
+
+
+def bulk_send_receipt_notifications(receipt_ids: List[str], notification_type: str = 'initial',
+ custom_message: str = '') -> Dict[str, int]:
+ """Bulk sending receipt notifications"""
+ service = ReceiptNotificationService()
+ return service.bulk_send_receipt_notifications(receipt_ids, notification_type, custom_message)
diff --git a/open_schools_platform/receipt_management/receipts/urls.py b/open_schools_platform/receipt_management/receipts/urls.py
new file mode 100644
index 00000000..65ea8004
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/urls.py
@@ -0,0 +1,25 @@
+from django.urls import path
+
+from open_schools_platform.common.views import MultipleViewManager
+from open_schools_platform.receipt_management.receipts.views import (
+ ReceiptCreateApi, ReceiptDetailApi, ReceiptListApi, FamilyReceiptListApi,
+ ReceiptPDFDownloadApi, ReceiptPDFUploadApi, BulkReceiptPDFUploadApi,
+ ReceiptNotificationSendApi, FamilyReceiptStatisticsApi
+)
+
+urlpatterns = [
+ path('', MultipleViewManager({'get': ReceiptListApi, 'post': ReceiptCreateApi}).as_view(),
+ name='receipts-list-create'),
+ path('/', ReceiptDetailApi.as_view(), name='receipt-detail'),
+
+ path('/family/', FamilyReceiptListApi.as_view(), name='family-receipts'),
+ path('/family//statistics', FamilyReceiptStatisticsApi.as_view(), name='family-receipt-statistics'),
+ path('/family//download', ReceiptPDFDownloadApi.as_view(), name='family-receipts-pdf-download'),
+
+ path('//download', ReceiptPDFDownloadApi.as_view(), name='receipt-pdf-download'),
+
+ path('/upload-pdf', ReceiptPDFUploadApi.as_view(), name='receipt-pdf-upload'),
+ path('/upload-bulk-pdf', BulkReceiptPDFUploadApi.as_view(), name='receipt-bulk-pdf-upload'),
+
+ path('/send-notifications', ReceiptNotificationSendApi.as_view(), name='receipt-send-notifications'),
+]
diff --git a/open_schools_platform/receipt_management/receipts/views.py b/open_schools_platform/receipt_management/receipts/views.py
new file mode 100644
index 00000000..e0587e78
--- /dev/null
+++ b/open_schools_platform/receipt_management/receipts/views.py
@@ -0,0 +1,339 @@
+from django.http import Http404
+from drf_yasg.utils import swagger_auto_schema
+from rest_framework import status, serializers
+from rest_framework.generics import ListAPIView
+from rest_framework.parsers import MultiPartParser
+from rest_framework.response import Response
+from rest_framework.views import APIView
+import logging # Added
+
+from open_schools_platform.api.mixins import ApiAuthMixin
+from open_schools_platform.api.pagination import get_paginated_response
+from open_schools_platform.api.swagger_tags import SwaggerTags
+from open_schools_platform.common.paginators import DefaultListPagination
+from open_schools_platform.common.views import convert_dict_to_serializer
+from open_schools_platform.parent_management.families.selectors import get_family
+from open_schools_platform.receipt_management.receipts.selectors import (
+ get_receipt, get_receipts_with_filters, get_receipts_for_family,
+ get_receipt_statistics_for_family, get_notifications_for_receipt,
+ get_receipt_notifications
+)
+from open_schools_platform.receipt_management.receipts.serializers import (
+ CreateReceiptSerializer, UpdateReceiptSerializer, GetReceiptSerializer,
+ GetReceiptListSerializer, GetReceiptDetailedSerializer,
+ UploadReceiptPDFSerializer, SendReceiptNotificationSerializer,
+ GetReceiptNotificationSerializer
+)
+from open_schools_platform.receipt_management.receipts.services import create_receipt, process_pdf_receipt, \
+ PDFDownloadService, update_receipt, bulk_send_receipt_notifications, process_pdf_receipts_bulk
+from open_schools_platform.student_management.students.selectors import get_student_profile
+
+logger = logging.getLogger(__name__) # Added
+
+
+class ReceiptCreateApi(ApiAuthMixin, APIView):
+ """
+ Create a new receipt (Dispatcher Web App)
+ """
+
+ @swagger_auto_schema(
+ operation_description="Create a new receipt for a student",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ request_body=CreateReceiptSerializer(),
+ responses={201: GetReceiptDetailedSerializer()}
+ )
+ def post(self, request):
+ serializer = CreateReceiptSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ receipt = create_receipt(**serializer.validated_data)
+
+ response_serializer = GetReceiptDetailedSerializer(receipt)
+ return Response(response_serializer.data, status=status.HTTP_201_CREATED)
+
+
+class BulkReceiptPDFUploadApi(ApiAuthMixin, APIView):
+ """
+ Upload and parse a PDF file containing multiple receipts (Dispatcher Web App)
+ """
+ parser_classes = [MultiPartParser]
+
+ @swagger_auto_schema(
+ operation_description="Upload a single PDF file containing multiple receipts. The system will attempt to parse each receipt, identify the student, and create corresponding receipt records.",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ request_body=UploadReceiptPDFSerializer,
+ responses={
+ 200: convert_dict_to_serializer({
+ "created_count": serializers.IntegerField(),
+ "failed_count": serializers.IntegerField(),
+ "created_receipts": GetReceiptDetailedSerializer(many=True),
+ "failed_details": serializers.ListField(child=serializers.DictField())
+ }),
+ 400: "Invalid PDF file or processing error"
+ }
+ )
+ def post(self, request):
+ serializer = UploadReceiptPDFSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ pdf_file = request.FILES.get('pdf_file')
+ student_profile_id = serializer.validated_data['student_profile_id']
+
+ if not pdf_file:
+ return Response({"detail": "No PDF file provided."}, status=status.HTTP_400_BAD_REQUEST)
+
+ if not pdf_file.name.endswith('.pdf'):
+ return Response({"detail": "File must be a PDF."}, status=status.HTTP_400_BAD_REQUEST)
+
+ try:
+ result = process_pdf_receipts_bulk(pdf_file, student_profile_id)
+
+ serialized_receipts = GetReceiptDetailedSerializer(result["created_receipts"], many=True).data
+
+ response_data = {
+ "created_count": result["created_count"],
+ "failed_count": result["failed_count"],
+ "created_receipts": serialized_receipts,
+ "failed_details": result["failed_details"]
+ }
+
+ return Response(response_data, status=status.HTTP_200_OK)
+ except ValueError as e:
+ return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
+ except Exception as e:
+ logger.error(f"Error processing bulk PDF upload: {str(e)}")
+ return Response({"detail": "An error occurred while processing the PDF."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
+class ReceiptDetailApi(ApiAuthMixin, APIView):
+ """
+ Get, update, or delete a specific receipt
+ """
+
+ @swagger_auto_schema(
+ operation_description="Get receipt details",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ responses={200: GetReceiptDetailedSerializer()}
+ )
+ def get(self, request, receipt_id):
+ receipt = get_receipt(filters={'id': receipt_id}, empty_exception=True)
+
+ # todo: Нужная сюда какая-то проверка на семью-родителя-ребенка-тд?
+
+ serializer = GetReceiptDetailedSerializer(receipt)
+ return Response(serializer.data)
+
+ @swagger_auto_schema(
+ operation_description="Update receipt details",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ request_body=UpdateReceiptSerializer(),
+ responses={200: GetReceiptDetailedSerializer()}
+ )
+ def patch(self, request, receipt_id):
+ receipt = get_receipt(filters={'id': receipt_id}, empty_exception=True)
+
+ serializer = UpdateReceiptSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ updated_receipt = update_receipt(receipt, **serializer.validated_data)
+
+ response_serializer = GetReceiptDetailedSerializer(updated_receipt)
+ return Response(response_serializer.data)
+
+ @swagger_auto_schema(
+ operation_description="Delete a receipt",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ responses={204: "Receipt deleted successfully"}
+ )
+ def delete(self, request, receipt_id):
+ receipt = get_receipt(filters={'id': receipt_id}, empty_exception=True)
+ receipt.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class ReceiptListApi(ApiAuthMixin, ListAPIView):
+ """
+ List receipts with filtering (for both parents and dispatchers)
+ """
+
+ pagination_class = DefaultListPagination
+
+ @swagger_auto_schema(
+ operation_description="Get list of receipts",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ responses={200: GetReceiptListSerializer(many=True)}
+ )
+ def get(self, request):
+ pagination_params = ['page', 'page_size', 'limit', 'offset']
+ filters = {key: value for key, value in request.GET.dict().items()
+ if key not in pagination_params}
+
+ # todo: Тоже здесь проверку прав накинуть
+
+ receipts = get_receipts_with_filters(user=request.user, filters=filters)
+
+ response = get_paginated_response(
+ pagination_class=self.pagination_class,
+ serializer_class=GetReceiptListSerializer,
+ queryset=receipts,
+ request=request,
+ view=self
+ )
+ return response
+
+
+class FamilyReceiptListApi(ApiAuthMixin, APIView):
+ """
+ Get receipts for a specific family (Parent Mobile App)
+ """
+
+ @swagger_auto_schema(
+ operation_description="Get receipts for a family",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ responses={200: convert_dict_to_serializer({
+ "results": GetReceiptListSerializer(many=True)
+ })}
+ )
+ def get(self, request, family_id):
+ family = get_family(filters={'id': family_id}, empty_exception=True)
+
+ receipts = get_receipts_for_family(family, request.GET.dict())
+ serializer = GetReceiptListSerializer(receipts, many=True)
+ return Response({"results": serializer.data})
+
+
+class ReceiptPDFDownloadApi(ApiAuthMixin, APIView):
+ """
+ Download receipt PDF (Parent Mobile App)
+ Can download a single receipt or a consolidated PDF for all receipts in a family.
+ """
+
+ @swagger_auto_schema(
+ operation_description="Download receipt PDF file. \n\n"
+ "If 'receipt_id' is provided, downloads a single receipt PDF.\n"
+ "If 'family_id' is provided, downloads a consolidated PDF of all receipts for that family.",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ responses={200: "PDF file"})
+ def get(self, request, receipt_id=None, family_id=None):
+ pdf_service = PDFDownloadService()
+
+ if receipt_id:
+ receipt = get_receipt(filters={'id': receipt_id}, user=request.user, empty_exception=True)
+ return pdf_service.download_single_receipt_pdf(receipt)
+
+ elif family_id:
+ family = get_family(filters={'id': family_id}, user=request.user, empty_exception=True)
+ return pdf_service.download_consolidated_family_pdf(family, request.GET.dict())
+
+ else:
+ return Response({"detail": "Either receipt_id or family_id must be provided."},
+ status=status.HTTP_400_BAD_REQUEST)
+
+
+class ReceiptPDFUploadApi(ApiAuthMixin, APIView):
+ """
+ Upload and parse PDF receipt (Dispatcher Web App)
+ """
+ parser_classes = [MultiPartParser]
+
+ @swagger_auto_schema(
+ operation_description="Upload PDF receipt file and create receipt",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ request_body=UploadReceiptPDFSerializer(),
+ responses={201: GetReceiptDetailedSerializer()}
+ )
+ def post(self, request):
+ serializer = UploadReceiptPDFSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ pdf_file = serializer.validated_data['pdf_file']
+ student_profile_id = serializer.validated_data['student_profile_id']
+
+ student_profile = get_student_profile(
+ filters={'id': student_profile_id},
+ empty_exception=True
+ )
+
+ receipt = process_pdf_receipt(pdf_file, student_profile)
+
+ response_serializer = GetReceiptDetailedSerializer(receipt)
+ return Response(response_serializer.data, status=status.HTTP_201_CREATED)
+
+
+class ReceiptNotificationSendApi(ApiAuthMixin, APIView):
+ """
+ Send receipt notifications (Dispatcher Web App)
+ """
+
+ @swagger_auto_schema(
+ operation_description="Send notifications for receipts",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ request_body=SendReceiptNotificationSerializer(),
+ responses={200: convert_dict_to_serializer({
+ "sent_count": serializers.IntegerField(),
+ "failed_count": serializers.IntegerField()
+ })}
+ )
+ def post(self, request):
+ serializer = SendReceiptNotificationSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ receipt_ids = serializer.validated_data['receipt_ids']
+ notification_type = serializer.validated_data['notification_type']
+ custom_message = serializer.validated_data.get('custom_message', '')
+
+ results = bulk_send_receipt_notifications(
+ receipt_ids, notification_type, custom_message
+ )
+
+ return Response({"results": results})
+
+
+class ReceiptNotificationListApi(ApiAuthMixin, APIView):
+ """
+ List receipt notifications (Dispatcher Web App)
+ """
+
+ @swagger_auto_schema(
+ operation_description="Get list of receipt notifications",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ responses={200: convert_dict_to_serializer({
+ "results": GetReceiptNotificationSerializer(many=True)
+ })}
+ )
+ def get(self, request, receipt_id=None):
+ if receipt_id:
+ receipt = get_receipt(filters={'id': receipt_id}, empty_exception=True)
+ notifications = get_notifications_for_receipt(receipt)
+ else:
+ filters = request.GET.dict()
+ notifications = get_receipt_notifications(filters=filters)
+
+ serializer = GetReceiptNotificationSerializer(notifications, many=True)
+ return Response({"results": serializer.data})
+
+
+class FamilyReceiptStatisticsApi(ApiAuthMixin, APIView):
+ """
+ Get receipt statistics for a family (Parent Mobile App)
+ """
+
+ @swagger_auto_schema(
+ operation_description="Get receipt statistics for a family",
+ tags=[SwaggerTags.RECEIPT_MANAGEMENT_RECEIPTS],
+ responses={200: convert_dict_to_serializer({
+ "total_receipts": serializers.IntegerField(),
+ "overdue_receipts": serializers.IntegerField(),
+ "viewed_receipts": serializers.IntegerField(),
+ "total_amount": serializers.DecimalField(max_digits=10, decimal_places=2)
+ })}
+ )
+ def get(self, request, family_id):
+ family = get_family(filters={'id': family_id}, empty_exception=True)
+
+ if hasattr(request.user, 'parent_profile'):
+ if family not in request.user.parent_profile.families.all():
+ raise Http404("Family not found")
+
+ statistics = get_receipt_statistics_for_family(family)
+ return Response({"statistics": statistics})
diff --git a/requirements/base.txt b/requirements/base.txt
index 69dfb4cc..ec06ec1e 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -41,4 +41,6 @@ sentry-sdk==1.8.0
icalendar==5.0.4
django-lifecycle==1.0.0
gunicorn==20.1.0
-drf-yasg==1.20.0
\ No newline at end of file
+drf-yasg==1.20.0
+pdfplumber==0.7.6
+pypdf>=3.0.0
\ No newline at end of file