diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0ae2156
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,33 @@
+*.pyc
+__pycache__
+*.pyo
+*.pyd
+.Python
+env/
+venv/
+ENV/
+.env
+.venv
+pip-log.txt
+pip-delete-this-directory.txt
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.log
+.git
+.gitignore
+.mypy_cache
+.pytest_cache
+.hypothesis
+db.sqlite3
+*.sqlite3
+.DS_Store
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
diff --git a/.env.sample b/.env.sample
new file mode 100644
index 0000000..215dfa2
--- /dev/null
+++ b/.env.sample
@@ -0,0 +1,29 @@
+# Django Configuration
+SECRET_KEY=your-super-secret-key-change-this-in-production
+DEBUG=True
+ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
+
+# Database Configuration - PostgreSQL (for Docker)
+DB_HOST=db
+DB_NAME=library_db
+DB_USER=library_user
+DB_PASSWORD=library_password
+DB_PORT=5432
+
+# For local development with SQLite (set to True)
+USE_SQLITE=False
+
+# Stripe Configuration
+STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
+STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key_here
+
+# Telegram Bot Configuration
+TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
+TELEGRAM_CHAT_ID=-1001234567890
+
+# Redis Configuration (for Django-Q)
+REDIS_HOST=redis
+REDIS_PORT=6379
+
+# Fine Multiplier (optional - default is 2)
+# FINE_MULTIPLIER=2
diff --git a/.gitignore b/.gitignore
index 7b8521d..d953e25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,27 @@
-.idea
-venv
\ No newline at end of file
+# Python
+venv/
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+
+# Django
+*.log
+db.sqlite3
+db.sqlite3-journal
+/staticfiles/
+/media/
+
+# Environment variables
+.env
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..846c965
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,30 @@
+FROM python:3.13-slim
+
+# Set environment variables
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+# Set work directory
+WORKDIR /app
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y \
+ gcc \
+ postgresql-client \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Python dependencies
+COPY requirements.txt /app/
+RUN pip install --upgrade pip && pip install -r requirements.txt
+
+# Copy project
+COPY . /app/
+
+# Create directory for static files
+RUN mkdir -p /app/staticfiles
+
+# Collect static files
+RUN python manage.py collectstatic --noinput || true
+
+# Run migrations and start server
+CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]
diff --git a/README.md b/README.md
index 204fa44..3c4a73c 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,90 @@
-# library-service
+# Library Service API
+
+A full-stack Django REST API for managing a library system: book inventory, borrowings, users, payments (Stripe), and Telegram notifications.
+
+## 📋 Project Description
+
+This project modernizes a traditional library by implementing an online management system for book borrowings. The system optimizes library administrators' work and makes the service more user-friendly.
+
+**Problem solved:**
+- Manual paper-based tracking of books, borrowings, and payments
+- No real-time inventory management
+- Cash-only payments
+- No automated overdue notifications
+
+**Solution:**
+- Web-based REST API for all library operations
+- Automated Stripe payment processing
+- Real-time Telegram notifications
+- JWT-based authentication
+- Scheduled daily overdue checks
+
+---
+
+## ✨ Features
+
+### Books Management
+- ✅ Full CRUD operations (admin only)
+- ✅ Public book listing and search
+- ✅ Inventory tracking
+- ✅ Cover type (HARD/SOFT) support
+
+### User Management
+- ✅ Custom user model with email authentication
+- ✅ JWT token-based authentication
+- ✅ User registration and profile management
+- ✅ Admin/staff role permissions
+
+### Borrowing System
+- ✅ Create borrowings with automatic inventory updates
+- ✅ Filter by user and active/returned status
+- ✅ Return functionality with fine calculation
+- ✅ Automatic payment creation on borrowing
+
+### Payment Processing
+- ✅ Stripe payment integration
+- ✅ Automatic payment session creation
+- ✅ Payment success/cancel webhooks
+- ✅ Fine calculation for overdue returns
+- ✅ Payment status tracking (PENDING/PAID)
+
+### Notifications
+- ✅ Telegram bot integration
+- ✅ New borrowing notifications
+- ✅ Daily overdue check notifications
+- ✅ Successful payment notifications
+
+### Background Tasks
+- ✅ Django-Q integration for async tasks
+- ✅ Scheduled daily overdue checks
+- ✅ Redis-backed task queue
+
+---
+
+## 🏗️ Architecture
+
+The system follows a microservices-inspired architecture with the following components:
+
+- **Books Service**: Manage book catalog and inventory
+- **Users Service**: Handle authentication and user profiles
+- **Borrowings Service**: Manage borrowing operations
+- **Payments Service**: Process payments via Stripe
+- **Notifications Service**: Send Telegram notifications
+- **Background Tasks**: Django-Q cluster for scheduled tasks
+
+All services communicate via REST API endpoints documented in Swagger.
+
+---
+
+## 🚀 Quick Start
+
+### Prerequisites
+
+- Python 3.13+
+- PostgreSQL 15+ (or SQLite for development)
+- Redis 7+
+- Docker & Docker Compose (optional)
+
+### Local Development Setup
+
+1. **Clone the repository**
diff --git a/books/__init__.py b/books/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/books/admin.py b/books/admin.py
new file mode 100644
index 0000000..5f72ff6
--- /dev/null
+++ b/books/admin.py
@@ -0,0 +1,12 @@
+from django.contrib import admin
+
+from books.models import Book
+
+
+@admin.register(Book)
+class BookAdmin(admin.ModelAdmin):
+ """Admin configuration for Book model."""
+
+ list_display = ["title", "author", "cover", "inventory", "daily_fee"]
+ list_filter = ["cover"]
+ search_fields = ["title", "author"]
diff --git a/books/apps.py b/books/apps.py
new file mode 100644
index 0000000..ca1a219
--- /dev/null
+++ b/books/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class BooksConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "books"
diff --git a/books/migrations/0001_initial.py b/books/migrations/0001_initial.py
new file mode 100644
index 0000000..4444b3b
--- /dev/null
+++ b/books/migrations/0001_initial.py
@@ -0,0 +1,41 @@
+# Generated by Django 4.2.7 on 2025-11-05 11:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="Book",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title", models.CharField(max_length=255)),
+ ("author", models.CharField(max_length=255)),
+ (
+ "cover",
+ models.CharField(
+ choices=[("HARD", "Hard cover"), ("SOFT", "Soft cover")],
+ default="SOFT",
+ max_length=4,
+ ),
+ ),
+ ("inventory", models.PositiveIntegerField()),
+ ("daily_fee", models.DecimalField(decimal_places=2, max_digits=6)),
+ ],
+ options={
+ "ordering": ["title"],
+ },
+ ),
+ ]
diff --git a/books/migrations/__init__.py b/books/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/books/models.py b/books/models.py
new file mode 100644
index 0000000..9b8f0d8
--- /dev/null
+++ b/books/models.py
@@ -0,0 +1,29 @@
+from django.db import models
+
+
+class Book(models.Model):
+ """Model representing a book in the library."""
+
+ COVER_HARD = "HARD"
+ COVER_SOFT = "SOFT"
+
+ COVER_CHOICES = [
+ (COVER_HARD, "Hard cover"),
+ (COVER_SOFT, "Soft cover"),
+ ]
+
+ title = models.CharField(max_length=255)
+ author = models.CharField(max_length=255)
+ cover = models.CharField(
+ max_length=4,
+ choices=COVER_CHOICES,
+ default=COVER_SOFT
+ )
+ inventory = models.PositiveIntegerField()
+ daily_fee = models.DecimalField(max_digits=6, decimal_places=2)
+
+ class Meta:
+ ordering = ["title"]
+
+ def __str__(self):
+ return f"{self.title} by {self.author}"
diff --git a/books/serializers.py b/books/serializers.py
new file mode 100644
index 0000000..23352e3
--- /dev/null
+++ b/books/serializers.py
@@ -0,0 +1,12 @@
+from rest_framework import serializers
+
+from books.models import Book
+
+
+class BookSerializer(serializers.ModelSerializer):
+ """Serializer for Book model."""
+
+ class Meta:
+ model = Book
+ fields = ["id", "title", "author", "cover", "inventory", "daily_fee"]
+ read_only_fields = ["id"]
diff --git a/books/tests.py b/books/tests.py
new file mode 100644
index 0000000..5ec3089
--- /dev/null
+++ b/books/tests.py
@@ -0,0 +1,63 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+from rest_framework import status
+from rest_framework.test import APIClient
+
+from books.models import Book
+
+
+class BookTests(TestCase):
+ """Tests for Book model and API."""
+
+ def setUp(self):
+ self.client = APIClient()
+ self.user = get_user_model().objects.create_user(
+ email="user@example.com", password="testpass123"
+ )
+ self.admin = get_user_model().objects.create_superuser(
+ email="admin@example.com", password="adminpass123"
+ )
+ self.book_data = {
+ "title": "Test Book",
+ "author": "Test Author",
+ "cover": "SOFT",
+ "inventory": 10,
+ "daily_fee": "1.50",
+ }
+ self.book = Book.objects.create(**self.book_data)
+
+ def test_list_books_unauthenticated(self):
+ """Test listing books without authentication."""
+ url = reverse("books:book-list")
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ def test_create_book_as_admin(self):
+ """Test creating book as admin."""
+ self.client.force_authenticate(user=self.admin)
+ url = reverse("books:book-list")
+ response = self.client.post(url, self.book_data)
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ self.assertEqual(response.data["title"], self.book_data["title"])
+
+ def test_create_book_as_user_forbidden(self):
+ """Test regular user cannot create books."""
+ self.client.force_authenticate(user=self.user)
+ url = reverse("books:book-list")
+ response = self.client.post(url, self.book_data)
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_update_book_as_admin(self):
+ """Test updating book as admin."""
+ self.client.force_authenticate(user=self.admin)
+ url = reverse("books:book-detail", args=[self.book.id])
+ updated_data = {"title": "Updated Title"}
+ response = self.client.patch(url, updated_data)
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.book.refresh_from_db()
+ self.assertEqual(self.book.title, "Updated Title")
diff --git a/books/urls.py b/books/urls.py
new file mode 100644
index 0000000..49faf14
--- /dev/null
+++ b/books/urls.py
@@ -0,0 +1,14 @@
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+from books.views import BookViewSet
+
+
+router = DefaultRouter()
+router.register("", BookViewSet, basename="book")
+
+urlpatterns = [
+ path("", include(router.urls)),
+]
+
+app_name = "books"
diff --git a/books/views.py b/books/views.py
new file mode 100644
index 0000000..71e9816
--- /dev/null
+++ b/books/views.py
@@ -0,0 +1,21 @@
+from rest_framework import viewsets
+from rest_framework.permissions import IsAdminUser, IsAuthenticatedOrReadOnly
+
+from books.models import Book
+from books.serializers import BookSerializer
+
+
+class BookViewSet(viewsets.ModelViewSet):
+ """ViewSet for managing books."""
+
+ queryset = Book.objects.all()
+ serializer_class = BookSerializer
+
+ def get_permissions(self):
+ """
+ Admin users can create/update/delete books.
+ All users (including unauthenticated) can list/retrieve books.
+ """
+ if self.action in ["create", "update", "partial_update", "destroy"]:
+ return [IsAdminUser()]
+ return [IsAuthenticatedOrReadOnly()]
diff --git a/booksurls.py b/booksurls.py
new file mode 100644
index 0000000..e69de29
diff --git a/borrowings/__init__.py b/borrowings/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/borrowings/admin.py b/borrowings/admin.py
new file mode 100644
index 0000000..5edc2a3
--- /dev/null
+++ b/borrowings/admin.py
@@ -0,0 +1,18 @@
+from django.contrib import admin
+
+from borrowings.models import Borrowing
+
+
+@admin.register(Borrowing)
+class BorrowingAdmin(admin.ModelAdmin):
+ """Admin configuration for Borrowing model."""
+
+ list_display = [
+ "book",
+ "user",
+ "borrow_date",
+ "expected_return_date",
+ "actual_return_date",
+ ]
+ list_filter = ["borrow_date", "actual_return_date"]
+ search_fields = ["book__title", "user__email"]
diff --git a/borrowings/apps.py b/borrowings/apps.py
new file mode 100644
index 0000000..9ab9f08
--- /dev/null
+++ b/borrowings/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class BorrowingsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "borrowings"
diff --git a/borrowings/migrations/0001_initial.py b/borrowings/migrations/0001_initial.py
new file mode 100644
index 0000000..e6ec965
--- /dev/null
+++ b/borrowings/migrations/0001_initial.py
@@ -0,0 +1,50 @@
+# Generated by Django 4.2.7 on 2025-11-05 11:29
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("books", "0001_initial"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Borrowing",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("borrow_date", models.DateField(auto_now_add=True)),
+ ("expected_return_date", models.DateField()),
+ ("actual_return_date", models.DateField(blank=True, null=True)),
+ (
+ "book",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="books.book"
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-borrow_date"],
+ },
+ ),
+ ]
diff --git a/borrowings/migrations/__init__.py b/borrowings/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/borrowings/models.py b/borrowings/models.py
new file mode 100644
index 0000000..41f35df
--- /dev/null
+++ b/borrowings/models.py
@@ -0,0 +1,20 @@
+from django.conf import settings
+from django.db import models
+
+from books.models import Book
+
+
+class Borrowing(models.Model):
+ """Model representing a book borrowing."""
+
+ borrow_date = models.DateField(auto_now_add=True)
+ expected_return_date = models.DateField()
+ actual_return_date = models.DateField(null=True, blank=True)
+ book = models.ForeignKey(Book, on_delete=models.CASCADE)
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+
+ class Meta:
+ ordering = ["-borrow_date"]
+
+ def __str__(self):
+ return f"{self.book.title} borrowed by {self.user.email}"
diff --git a/borrowings/serializers.py b/borrowings/serializers.py
new file mode 100644
index 0000000..1dfa576
--- /dev/null
+++ b/borrowings/serializers.py
@@ -0,0 +1,54 @@
+from rest_framework import serializers
+
+from books.serializers import BookSerializer
+from borrowings.models import Borrowing
+
+
+class BorrowingSerializer(serializers.ModelSerializer):
+ """Serializer for Borrowing model."""
+
+ class Meta:
+ model = Borrowing
+ fields = [
+ "id",
+ "borrow_date",
+ "expected_return_date",
+ "actual_return_date",
+ "book",
+ "user",
+ ]
+ read_only_fields = ["id", "borrow_date", "user"]
+
+
+class BorrowingDetailSerializer(BorrowingSerializer):
+ """Detailed serializer with nested book info and payments."""
+
+ book = BookSerializer(read_only=True)
+ payments = serializers.SerializerMethodField()
+
+ class Meta(BorrowingSerializer.Meta):
+ fields = BorrowingSerializer.Meta.fields + ["payments"]
+
+ def get_payments(self, obj):
+ """Return all payments for this borrowing."""
+ from payments.serializers import PaymentSerializer
+
+ payments = obj.payments.all()
+ return PaymentSerializer(payments, many=True).data
+
+
+class BorrowingCreateSerializer(serializers.ModelSerializer):
+ """Serializer for creating borrowings."""
+
+ class Meta:
+ model = Borrowing
+ fields = ["id", "expected_return_date", "book"]
+ read_only_fields = ["id"]
+
+ def validate_book(self, value):
+ """Validate that book has available inventory."""
+ if value.inventory < 1:
+ raise serializers.ValidationError(
+ "This book is currently out of stock."
+ )
+ return value
diff --git a/borrowings/tests.py b/borrowings/tests.py
new file mode 100644
index 0000000..74937f6
--- /dev/null
+++ b/borrowings/tests.py
@@ -0,0 +1,129 @@
+from datetime import date, timedelta
+from decimal import Decimal
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+from rest_framework import status
+from rest_framework.test import APIClient
+
+from books.models import Book
+from borrowings.models import Borrowing
+
+
+class BorrowingTests(TestCase):
+ """Tests for Borrowing model and API."""
+
+ def setUp(self):
+ self.client = APIClient()
+ self.user = get_user_model().objects.create_user(
+ email="user@example.com", password="testpass123"
+ )
+ self.admin = get_user_model().objects.create_superuser(
+ email="admin@example.com", password="adminpass123"
+ )
+ self.book = Book.objects.create(
+ title="Test Book",
+ author="Test Author",
+ cover="SOFT",
+ inventory=5,
+ daily_fee=Decimal("2.00"),
+ )
+
+ @patch("payments.stripe_helper.create_stripe_session")
+ @patch("notifications.telegram_helper.notify_new_borrowing")
+ def test_create_borrowing(self, mock_notify, mock_stripe):
+ """Test creating a borrowing."""
+ self.client.force_authenticate(user=self.user)
+ url = reverse("borrowings:borrowing-list")
+ data = {
+ "book": self.book.id,
+ "expected_return_date": (date.today() + timedelta(days=7)).isoformat(),
+ }
+ response = self.client.post(url, data)
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ self.book.refresh_from_db()
+ self.assertEqual(self.book.inventory, 4)
+ mock_stripe.assert_called_once()
+ mock_notify.assert_called_once()
+
+ def test_list_borrowings_user_sees_own(self):
+ """Test users see only their own borrowings."""
+ other_user = get_user_model().objects.create_user(
+ email="other@example.com", password="testpass123"
+ )
+ Borrowing.objects.create(
+ book=self.book,
+ user=self.user,
+ expected_return_date=date.today() + timedelta(days=7),
+ )
+ Borrowing.objects.create(
+ book=self.book,
+ user=other_user,
+ expected_return_date=date.today() + timedelta(days=7),
+ )
+
+ self.client.force_authenticate(user=self.user)
+ url = reverse("borrowings:borrowing-list")
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(len(response.data["results"]), 1)
+
+ def test_list_borrowings_admin_sees_all(self):
+ """Test admin sees all borrowings."""
+ Borrowing.objects.create(
+ book=self.book,
+ user=self.user,
+ expected_return_date=date.today() + timedelta(days=7),
+ )
+ Borrowing.objects.create(
+ book=self.book,
+ user=self.admin,
+ expected_return_date=date.today() + timedelta(days=7),
+ )
+
+ self.client.force_authenticate(user=self.admin)
+ url = reverse("borrowings:borrowing-list")
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(len(response.data["results"]), 2)
+
+ @patch("payments.stripe_helper.create_fine_payment")
+ def test_return_book(self, mock_fine):
+ """Test returning a book."""
+ borrowing = Borrowing.objects.create(
+ book=self.book,
+ user=self.user,
+ expected_return_date=date.today() + timedelta(days=7),
+ )
+ self.book.inventory = 4
+ self.book.save()
+
+ self.client.force_authenticate(user=self.user)
+ url = reverse("borrowings:borrowing-return-book", args=[borrowing.id])
+ response = self.client.post(url, {})
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ borrowing.refresh_from_db()
+ self.book.refresh_from_db()
+ self.assertIsNotNone(borrowing.actual_return_date)
+ self.assertEqual(self.book.inventory, 5)
+
+ def test_cannot_borrow_out_of_stock(self):
+ """Test cannot borrow when inventory is 0."""
+ self.book.inventory = 0
+ self.book.save()
+
+ self.client.force_authenticate(user=self.user)
+ url = reverse("borrowings:borrowing-list")
+ data = {
+ "book": self.book.id,
+ "expected_return_date": (date.today() + timedelta(days=7)).isoformat(),
+ }
+ response = self.client.post(url, data)
+
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
diff --git a/borrowings/urls.py b/borrowings/urls.py
new file mode 100644
index 0000000..87e2de4
--- /dev/null
+++ b/borrowings/urls.py
@@ -0,0 +1,14 @@
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+from borrowings.views import BorrowingViewSet
+
+
+router = DefaultRouter()
+router.register("", BorrowingViewSet, basename="borrowing")
+
+urlpatterns = [
+ path("", include(router.urls)),
+]
+
+app_name = "borrowings"
diff --git a/borrowings/views.py b/borrowings/views.py
new file mode 100644
index 0000000..4068a2e
--- /dev/null
+++ b/borrowings/views.py
@@ -0,0 +1,104 @@
+from datetime import date
+
+from rest_framework import mixins, status, viewsets
+from rest_framework.decorators import action
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+from borrowings.models import Borrowing
+from borrowings.serializers import (
+ BorrowingCreateSerializer,
+ BorrowingDetailSerializer,
+ BorrowingSerializer,
+)
+
+
+class BorrowingViewSet(
+ mixins.ListModelMixin,
+ mixins.CreateModelMixin,
+ mixins.RetrieveModelMixin,
+ viewsets.GenericViewSet,
+):
+ """ViewSet for managing borrowings."""
+
+ queryset = Borrowing.objects.select_related("book", "user")
+ serializer_class = BorrowingSerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ """Filter borrowings by user (non-admin see only their own)."""
+ queryset = self.queryset
+ user = self.request.user
+
+ if not user.is_staff:
+ queryset = queryset.filter(user=user)
+
+ is_active = self.request.query_params.get("is_active")
+ if is_active:
+ queryset = queryset.filter(actual_return_date__isnull=True)
+
+ user_id = self.request.query_params.get("user_id")
+ if user_id and user.is_staff:
+ queryset = queryset.filter(user_id=user_id)
+
+ return queryset
+
+ def get_serializer_class(self):
+ """Return appropriate serializer based on action."""
+ if self.action == "retrieve":
+ return BorrowingDetailSerializer
+ if self.action == "create":
+ return BorrowingCreateSerializer
+ return BorrowingSerializer
+
+ def perform_create(self, serializer):
+ """
+ Create borrowing, attach user, decrease book inventory,
+ create Stripe payment session, send Telegram notification.
+ """
+ from notifications.telegram_helper import notify_new_borrowing
+ from payments.stripe_helper import create_stripe_session
+
+ book = serializer.validated_data["book"]
+ book.inventory -= 1
+ book.save()
+
+ borrowing = serializer.save(user=self.request.user)
+
+ # Create Stripe payment session automatically
+ create_stripe_session(borrowing, self.request)
+
+ # Send Telegram notification about new borrowing
+ notify_new_borrowing(borrowing)
+
+ @action(detail=True, methods=["post"], url_path="return")
+ def return_book(self, request, pk=None):
+ """Return a borrowed book and create fine if overdue."""
+ from payments.stripe_helper import create_fine_payment
+
+ borrowing = self.get_object()
+
+ if borrowing.actual_return_date:
+ return Response(
+ {"error": "Book already returned."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Set actual return date (from request or today)
+ actual_date = request.data.get("actual_return_date")
+ if actual_date:
+ borrowing.actual_return_date = actual_date
+ else:
+ borrowing.actual_return_date = date.today()
+
+ # Increase book inventory
+ borrowing.book.inventory += 1
+ borrowing.book.save()
+ borrowing.save()
+
+ # Create fine payment if book is returned late
+ if borrowing.actual_return_date > borrowing.expected_return_date:
+ create_fine_payment(borrowing, request)
+
+ serializer = self.get_serializer(borrowing)
+ return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..968882f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,61 @@
+services:
+ db:
+ image: postgres:15-alpine
+ container_name: library-service-db
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ environment:
+ POSTGRES_DB: library_db
+ POSTGRES_USER: library_user
+ POSTGRES_PASSWORD: library_password
+ ports:
+ - "5433:5432"
+ restart: unless-stopped
+
+ redis:
+ image: redis:7-alpine
+ container_name: library-service-redis
+ ports:
+ - "6379:6379"
+ restart: unless-stopped
+
+ web:
+ build: .
+ container_name: library-service-web
+ command: sh -c "sleep 20 && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
+ volumes:
+ - .:/app
+ ports:
+ - "8000:8000"
+ env_file:
+ - .env
+ environment:
+ - DEBUG=True
+ - DB_HOST=db
+ - DB_NAME=library_db
+ - DB_USER=library_user
+ - DB_PASSWORD=library_password
+ - REDIS_HOST=redis
+ - REDIS_PORT=6379
+ restart: unless-stopped
+
+ qcluster:
+ build: .
+ container_name: library-service-qcluster
+ command: sh -c "sleep 30 && python manage.py qcluster"
+ volumes:
+ - .:/app
+ env_file:
+ - .env
+ environment:
+ - DEBUG=True
+ - DB_HOST=db
+ - DB_NAME=library_db
+ - DB_USER=library_user
+ - DB_PASSWORD=library_password
+ - REDIS_HOST=redis
+ - REDIS_PORT=6379
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
diff --git a/library_service/__init__.py b/library_service/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/library_service/asgi.py b/library_service/asgi.py
new file mode 100644
index 0000000..841ed7c
--- /dev/null
+++ b/library_service/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for library_service project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "library_service.settings")
+
+application = get_asgi_application()
diff --git a/library_service/settings.py b/library_service/settings.py
new file mode 100644
index 0000000..1ce3be2
--- /dev/null
+++ b/library_service/settings.py
@@ -0,0 +1,241 @@
+"""
+Django settings for library_service project.
+
+Generated by 'django-admin startproject' using Django 4.2.7.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.2/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/4.2/ref/settings/
+"""
+
+import os
+from datetime import timedelta
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+
+load_dotenv()
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = os.getenv(
+ "SECRET_KEY",
+ "django-insecure-default-key-change-me"
+)
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = os.getenv("DEBUG", "False") == "True"
+
+ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ # Third party apps
+ "rest_framework",
+ "rest_framework_simplejwt",
+ "drf_spectacular",
+ "django_q",
+ # Local apps
+ "books.apps.BooksConfig",
+ "users.apps.UsersConfig",
+ "borrowings.apps.BorrowingsConfig",
+ "payments.apps.PaymentsConfig",
+ "notifications",
+]
+
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+]
+
+ROOT_URLCONF = "library_service.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = "library_service.wsgi.application"
+
+
+# Database
+# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
+
+# PostgreSQL configuration for Docker
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql",
+ "NAME": os.getenv("DB_NAME", "library_db"),
+ "USER": os.getenv("DB_USER", "library_user"),
+ "PASSWORD": os.getenv("DB_PASSWORD", "library_password"),
+ "HOST": os.getenv("DB_HOST", "localhost"),
+ "PORT": os.getenv("DB_PORT", "5432"),
+ }
+}
+
+# Fallback to SQLite for local development
+if os.getenv("USE_SQLITE", "False") == "True":
+ DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": BASE_DIR / "db.sqlite3",
+ }
+ }
+
+# Custom User Model
+AUTH_USER_MODEL = "users.User"
+
+
+# Password validation
+# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": "django.contrib.auth.password_validation."
+ "UserAttributeSimilarityValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation."
+ "MinimumLengthValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation."
+ "CommonPasswordValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation."
+ "NumericPasswordValidator",
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/4.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/4.2/howto/static-files/
+
+STATIC_URL = "static/"
+STATIC_ROOT = BASE_DIR / "staticfiles"
+
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+
+# REST Framework Configuration
+REST_FRAMEWORK = {
+ "DEFAULT_AUTHENTICATION_CLASSES": (
+ "rest_framework_simplejwt.authentication.JWTAuthentication",
+ ),
+ "DEFAULT_PERMISSION_CLASSES": (
+ "rest_framework.permissions.IsAuthenticated",
+ ),
+ "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
+ "DEFAULT_PAGINATION_CLASS": (
+ "rest_framework.pagination.PageNumberPagination"
+ ),
+ "PAGE_SIZE": 10,
+}
+
+
+# JWT Settings
+SIMPLE_JWT = {
+ "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
+ "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
+ "ROTATE_REFRESH_TOKENS": False,
+ "BLACKLIST_AFTER_ROTATION": False,
+ "AUTH_HEADER_TYPES": ("Bearer",),
+ "AUTH_HEADER_NAME": "HTTP_AUTHORIZE",
+}
+
+
+# DRF Spectacular (Swagger) Settings
+SPECTACULAR_SETTINGS = {
+ "TITLE": "Library Service API",
+ "DESCRIPTION": (
+ "API for managing library books, borrowings, users, and payments. "
+ "Supports user authentication with JWT tokens, book inventory "
+ "management, borrowing tracking, Stripe payment integration, "
+ "and Telegram notifications."
+ ),
+ "VERSION": "1.0.0",
+ "SERVE_INCLUDE_SCHEMA": False,
+ "COMPONENT_SPLIT_REQUEST": True,
+}
+
+
+# Stripe Configuration
+STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
+STRIPE_PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
+
+
+# Telegram Bot Configuration
+TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
+TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
+
+
+# Django-Q Configuration for async tasks
+Q_CLUSTER = {
+ "name": "library_service",
+ "workers": 4,
+ "timeout": 90,
+ "retry": 120,
+ "queue_limit": 50,
+ "bulk": 10,
+ "orm": "default",
+ "redis": {
+ "host": os.getenv("REDIS_HOST", "localhost"),
+ "port": int(os.getenv("REDIS_PORT", 6379)),
+ "db": 0,
+ },
+}
+
+
+# Fine multiplier for overdue borrowings
+# Example: If daily fee is $1 and overdue by 2 days, fine = 2 * 1 * 2 = $4
+FINE_MULTIPLIER = 2
diff --git a/library_service/urls.py b/library_service/urls.py
new file mode 100644
index 0000000..f82ac3f
--- /dev/null
+++ b/library_service/urls.py
@@ -0,0 +1,21 @@
+from django.contrib import admin
+from django.urls import include, path
+from drf_spectacular.views import (
+ SpectacularAPIView,
+ SpectacularSwaggerView,
+)
+
+
+urlpatterns = [
+ path("admin/", admin.site.urls),
+ path("api/books/", include("books.urls")),
+ path("api/users/", include("users.urls")),
+ path("api/borrowings/", include("borrowings.urls")),
+ path("api/payments/", include("payments.urls")),
+ path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
+ path(
+ "api/docs/",
+ SpectacularSwaggerView.as_view(url_name="schema"),
+ name="docs"
+ ),
+]
diff --git a/library_service/wsgi.py b/library_service/wsgi.py
new file mode 100644
index 0000000..f9442bf
--- /dev/null
+++ b/library_service/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for library_service project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "library_service.settings")
+
+application = get_wsgi_application()
diff --git a/manage.py b/manage.py
new file mode 100644
index 0000000..649dddf
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "library_service.settings")
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/notifications/management/__init__.py b/notifications/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/notifications/management/commands/__init__.py b/notifications/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/notifications/management/commands/setup_schedule.py b/notifications/management/commands/setup_schedule.py
new file mode 100644
index 0000000..20047f1
--- /dev/null
+++ b/notifications/management/commands/setup_schedule.py
@@ -0,0 +1,17 @@
+from django.core.management.base import BaseCommand
+
+from notifications.tasks import schedule_overdue_check
+
+
+class Command(BaseCommand):
+ """Django command to set up scheduled tasks."""
+
+ help = "Set up scheduled tasks for checking overdue borrowings"
+
+ def handle(self, *args, **options):
+ schedule_overdue_check()
+ self.stdout.write(
+ self.style.SUCCESS(
+ "Successfully scheduled overdue borrowings check"
+ )
+ )
diff --git a/notifications/tasks.py b/notifications/tasks.py
new file mode 100644
index 0000000..06da4fa
--- /dev/null
+++ b/notifications/tasks.py
@@ -0,0 +1,35 @@
+from datetime import date
+
+from django_q.tasks import schedule
+
+from borrowings.models import Borrowing
+from notifications.telegram_helper import (
+ notify_overdue_borrowing,
+ send_telegram_message,
+)
+
+
+def check_overdue_borrowings():
+ """Check for overdue borrowings and send notifications."""
+ today = date.today()
+
+ overdue_borrowings = Borrowing.objects.filter(
+ expected_return_date__lt=today,
+ actual_return_date__isnull=True
+ ).select_related("book", "user")
+
+ if not overdue_borrowings.exists():
+ send_telegram_message("✅ No borrowings overdue today!")
+ return
+
+ for borrowing in overdue_borrowings:
+ notify_overdue_borrowing(borrowing)
+
+
+def schedule_overdue_check():
+ """Schedule daily task to check overdue borrowings."""
+ schedule(
+ "notifications.tasks.check_overdue_borrowings",
+ schedule_type="D", # D = Daily
+ repeats=-1, # Repeat indefinitely
+ )
diff --git a/notifications/telegram_helper.py b/notifications/telegram_helper.py
new file mode 100644
index 0000000..7bfcbf7
--- /dev/null
+++ b/notifications/telegram_helper.py
@@ -0,0 +1,72 @@
+import os
+
+import requests
+from django.conf import settings
+
+
+def send_telegram_message(message):
+ """Send a message to Telegram chat via Bot API."""
+ bot_token = settings.TELEGRAM_BOT_TOKEN
+ chat_id = settings.TELEGRAM_CHAT_ID
+
+ if not bot_token or not chat_id:
+ print("Telegram credentials not configured. Skipping notification.")
+ return False
+
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+ payload = {
+ "chat_id": chat_id,
+ "text": message,
+ "parse_mode": "HTML",
+ }
+
+ try:
+ response = requests.post(url, json=payload, timeout=10)
+ response.raise_for_status()
+ return True
+ except requests.RequestException as e:
+ print(f"Failed to send Telegram message: {e}")
+ return False
+
+
+def notify_new_borrowing(borrowing):
+ """Send notification about new borrowing."""
+ message = (
+ f"📚 New Borrowing Created\n\n"
+ f"Book: {borrowing.book.title}\n"
+ f"Author: {borrowing.book.author}\n"
+ f"Borrowed by: {borrowing.user.email}\n"
+ f"Borrow date: {borrowing.borrow_date}\n"
+ f"Expected return: {borrowing.expected_return_date}\n"
+ )
+ send_telegram_message(message)
+
+
+def notify_overdue_borrowing(borrowing):
+ """Send notification about overdue borrowing."""
+ from datetime import date
+
+ days_overdue = (date.today() - borrowing.expected_return_date).days
+
+ message = (
+ f"⚠️ Overdue Borrowing Alert\n\n"
+ f"Book: {borrowing.book.title}\n"
+ f"Author: {borrowing.book.author}\n"
+ f"Borrowed by: {borrowing.user.email}\n"
+ f"Expected return: {borrowing.expected_return_date}\n"
+ f"Days overdue: {days_overdue}\n"
+ )
+ send_telegram_message(message)
+
+
+def notify_successful_payment(payment):
+ """Send notification about successful payment."""
+ message = (
+ f"✅ Payment Successful\n\n"
+ f"Payment ID: {payment.id}\n"
+ f"Type: {payment.type}\n"
+ f"Amount: ${payment.money_to_pay}\n"
+ f"Book: {payment.borrowing.book.title}\n"
+ f"User: {payment.borrowing.user.email}\n"
+ )
+ send_telegram_message(message)
diff --git a/payments/__init__.py b/payments/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/payments/admin.py b/payments/admin.py
new file mode 100644
index 0000000..817edfa
--- /dev/null
+++ b/payments/admin.py
@@ -0,0 +1,18 @@
+from django.contrib import admin
+
+from payments.models import Payment
+
+
+@admin.register(Payment)
+class PaymentAdmin(admin.ModelAdmin):
+ """Admin configuration for Payment model."""
+
+ list_display = [
+ "id",
+ "borrowing",
+ "status",
+ "type",
+ "money_to_pay",
+ ]
+ list_filter = ["status", "type"]
+ search_fields = ["borrowing__book__title", "borrowing__user__email"]
diff --git a/payments/apps.py b/payments/apps.py
new file mode 100644
index 0000000..bf05bf1
--- /dev/null
+++ b/payments/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class PaymentsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "payments"
diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py
new file mode 100644
index 0000000..c83b61c
--- /dev/null
+++ b/payments/migrations/0001_initial.py
@@ -0,0 +1,59 @@
+# Generated by Django 4.2.7 on 2025-11-05 11:55
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("borrowings", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Payment",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "status",
+ models.CharField(
+ choices=[("PENDING", "Pending"), ("PAID", "Paid")],
+ default="PENDING",
+ max_length=10,
+ ),
+ ),
+ (
+ "type",
+ models.CharField(
+ choices=[("PAYMENT", "Payment"), ("FINE", "Fine")],
+ default="PAYMENT",
+ max_length=10,
+ ),
+ ),
+ ("session_url", models.URLField(max_length=500)),
+ ("session_id", models.CharField(max_length=255)),
+ ("money_to_pay", models.DecimalField(decimal_places=2, max_digits=10)),
+ (
+ "borrowing",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="payments",
+ to="borrowings.borrowing",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-id"],
+ },
+ ),
+ ]
diff --git a/payments/migrations/__init__.py b/payments/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/payments/models.py b/payments/models.py
new file mode 100644
index 0000000..3e5293e
--- /dev/null
+++ b/payments/models.py
@@ -0,0 +1,48 @@
+from django.db import models
+
+from borrowings.models import Borrowing
+
+
+class Payment(models.Model):
+ """Model representing a payment for borrowing."""
+
+ STATUS_PENDING = "PENDING"
+ STATUS_PAID = "PAID"
+
+ STATUS_CHOICES = [
+ (STATUS_PENDING, "Pending"),
+ (STATUS_PAID, "Paid"),
+ ]
+
+ TYPE_PAYMENT = "PAYMENT"
+ TYPE_FINE = "FINE"
+
+ TYPE_CHOICES = [
+ (TYPE_PAYMENT, "Payment"),
+ (TYPE_FINE, "Fine"),
+ ]
+
+ status = models.CharField(
+ max_length=10,
+ choices=STATUS_CHOICES,
+ default=STATUS_PENDING
+ )
+ type = models.CharField(
+ max_length=10,
+ choices=TYPE_CHOICES,
+ default=TYPE_PAYMENT
+ )
+ borrowing = models.ForeignKey(
+ Borrowing,
+ on_delete=models.CASCADE,
+ related_name="payments"
+ )
+ session_url = models.URLField(max_length=500)
+ session_id = models.CharField(max_length=255)
+ money_to_pay = models.DecimalField(max_digits=10, decimal_places=2)
+
+ class Meta:
+ ordering = ["-id"]
+
+ def __str__(self):
+ return f"Payment {self.id} - {self.status}"
diff --git a/payments/serializers.py b/payments/serializers.py
new file mode 100644
index 0000000..588288a
--- /dev/null
+++ b/payments/serializers.py
@@ -0,0 +1,20 @@
+from rest_framework import serializers
+
+from payments.models import Payment
+
+
+class PaymentSerializer(serializers.ModelSerializer):
+ """Serializer for Payment model."""
+
+ class Meta:
+ model = Payment
+ fields = [
+ "id",
+ "status",
+ "type",
+ "borrowing",
+ "session_url",
+ "session_id",
+ "money_to_pay",
+ ]
+ read_only_fields = ["id"]
diff --git a/payments/stripe_helper.py b/payments/stripe_helper.py
new file mode 100644
index 0000000..a3c366d
--- /dev/null
+++ b/payments/stripe_helper.py
@@ -0,0 +1,134 @@
+from decimal import Decimal
+
+import stripe
+from django.conf import settings
+from django.urls import reverse
+
+from payments.models import Payment
+
+
+stripe.api_key = settings.STRIPE_SECRET_KEY
+
+
+def create_stripe_session(borrowing, request):
+ """Create Stripe checkout session for borrowing."""
+
+ # Calculate total price (days * daily_fee)
+ days = (borrowing.expected_return_date - borrowing.borrow_date).days
+ if days < 1:
+ days = 1
+
+ # Convert days to Decimal and multiply by daily_fee
+ total_price = Decimal(days) * borrowing.book.daily_fee
+
+ # Convert to cents for Stripe
+ unit_amount = int(total_price * 100)
+
+ # Build success and cancel URLs
+ success_url = request.build_absolute_uri(
+ reverse("payments:payment-success")
+ )
+ cancel_url = request.build_absolute_uri(
+ reverse("payments:payment-cancel")
+ )
+
+ # Create Stripe session
+ session = stripe.checkout.Session.create(
+ payment_method_types=["card"],
+ line_items=[
+ {
+ "price_data": {
+ "currency": "usd",
+ "product_data": {
+ "name": f"Borrowing: {borrowing.book.title}",
+ "description": f"Book rental for {days} days",
+ },
+ "unit_amount": unit_amount,
+ },
+ "quantity": 1,
+ }
+ ],
+ mode="payment",
+ success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
+ cancel_url=cancel_url,
+ )
+
+ # Create Payment record
+ payment = Payment.objects.create(
+ borrowing=borrowing,
+ session_url=session.url,
+ session_id=session.id,
+ money_to_pay=total_price,
+ type=Payment.TYPE_PAYMENT,
+ status=Payment.STATUS_PENDING,
+ )
+
+ return payment
+
+
+def create_fine_payment(borrowing, request):
+ """Create fine payment for overdue borrowing."""
+
+ # Calculate overdue days
+ overdue_days = (
+ borrowing.actual_return_date - borrowing.expected_return_date
+ ).days
+ if overdue_days <= 0:
+ return None
+
+ # Calculate fine amount - convert all to Decimal
+ fine_amount = (
+ Decimal(overdue_days)
+ * borrowing.book.daily_fee
+ * Decimal(settings.FINE_MULTIPLIER)
+ )
+
+ # Convert to cents for Stripe
+ unit_amount = int(fine_amount * 100)
+
+ # Build URLs
+ success_url = request.build_absolute_uri(
+ reverse("payments:payment-success")
+ )
+ cancel_url = request.build_absolute_uri(
+ reverse("payments:payment-cancel")
+ )
+
+ # Create Stripe session
+ session = stripe.checkout.Session.create(
+ payment_method_types=["card"],
+ line_items=[
+ {
+ "price_data": {
+ "currency": "usd",
+ "product_data": {
+ "name": f"FINE: {borrowing.book.title}",
+ "description": f"Overdue fine for {overdue_days} days",
+ },
+ "unit_amount": unit_amount,
+ },
+ "quantity": 1,
+ }
+ ],
+ mode="payment",
+ success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
+ cancel_url=cancel_url,
+ )
+
+ # Create Payment record
+ payment = Payment.objects.create(
+ borrowing=borrowing,
+ session_url=session.url,
+ session_id=session.id,
+ money_to_pay=fine_amount,
+ type=Payment.TYPE_FINE,
+ status=Payment.STATUS_PENDING,
+ )
+
+ return payment
+
+
+def check_session_status(session_id):
+ """Check Stripe session payment status."""
+ session = stripe.checkout.Session.retrieve(session_id)
+ return session.payment_status == "paid"
diff --git a/payments/tests.py b/payments/tests.py
new file mode 100644
index 0000000..e89f236
--- /dev/null
+++ b/payments/tests.py
@@ -0,0 +1,58 @@
+from datetime import date, timedelta
+from decimal import Decimal
+from unittest.mock import MagicMock, patch
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from books.models import Book
+from borrowings.models import Borrowing
+from payments.models import Payment
+
+
+class PaymentTests(TestCase):
+ """Tests for Payment model and Stripe integration."""
+
+ def setUp(self):
+ self.user = get_user_model().objects.create_user(
+ email="user@example.com", password="testpass123"
+ )
+ self.book = Book.objects.create(
+ title="Test Book",
+ author="Test Author",
+ cover="SOFT",
+ inventory=5,
+ daily_fee=Decimal("2.00"),
+ )
+ self.borrowing = Borrowing.objects.create(
+ book=self.book,
+ user=self.user,
+ expected_return_date=date.today() + timedelta(days=5),
+ )
+
+ @patch("payments.stripe_helper.reverse")
+ @patch("payments.stripe_helper.stripe.checkout.Session.create")
+ def test_create_stripe_session(self, mock_stripe, mock_reverse):
+ """Test creating Stripe checkout session."""
+ # Mock reverse to return dummy URLs
+ mock_reverse.return_value = "/payments/success/"
+
+ # Mock Stripe session response
+ mock_session = MagicMock()
+ mock_session.url = "https://checkout.stripe.com/session"
+ mock_session.id = "cs_test_123"
+ mock_stripe.return_value = mock_session
+
+ from django.test import RequestFactory
+ from payments.stripe_helper import create_stripe_session
+
+ request = RequestFactory().get("/")
+
+ payment = create_stripe_session(self.borrowing, request)
+
+ self.assertIsInstance(payment, Payment)
+ self.assertEqual(payment.borrowing, self.borrowing)
+ self.assertEqual(payment.type, Payment.TYPE_PAYMENT)
+ self.assertEqual(payment.status, Payment.STATUS_PENDING)
+ self.assertEqual(payment.money_to_pay, Decimal("10.00")) # 5 days * $2
+ mock_stripe.assert_called_once()
diff --git a/payments/urls.py b/payments/urls.py
new file mode 100644
index 0000000..b7db97a
--- /dev/null
+++ b/payments/urls.py
@@ -0,0 +1,14 @@
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+from payments.views import PaymentViewSet
+
+
+router = DefaultRouter()
+router.register("", PaymentViewSet, basename="payment")
+
+urlpatterns = [
+ path("", include(router.urls)),
+]
+
+app_name = "payments"
diff --git a/payments/views.py b/payments/views.py
new file mode 100644
index 0000000..04538f2
--- /dev/null
+++ b/payments/views.py
@@ -0,0 +1,80 @@
+from rest_framework import mixins, status, viewsets
+from rest_framework.decorators import action
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+from payments.models import Payment
+from payments.serializers import PaymentSerializer
+from payments.stripe_helper import check_session_status
+
+
+class PaymentViewSet(
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ viewsets.GenericViewSet,
+):
+ """ViewSet for managing payments."""
+
+ queryset = Payment.objects.select_related("borrowing__book", "borrowing__user")
+ serializer_class = PaymentSerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ """Non-admin users see only their own payments."""
+ queryset = self.queryset
+ user = self.request.user
+
+ if not user.is_staff:
+ queryset = queryset.filter(borrowing__user=user)
+
+ return queryset
+
+ @action(detail=False, methods=["get"], url_path="success")
+ def payment_success(self, request):
+ """Handle successful payment and send Telegram notification."""
+ from notifications.telegram_helper import notify_successful_payment
+
+ session_id = request.query_params.get("session_id")
+
+ if not session_id:
+ return Response(
+ {"error": "Session ID is required."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ try:
+ payment = Payment.objects.get(session_id=session_id)
+
+ if check_session_status(session_id):
+ payment.status = Payment.STATUS_PAID
+ payment.save()
+
+ # Send Telegram notification about successful payment
+ notify_successful_payment(payment)
+
+ return Response(
+ {"message": "Payment successful!"},
+ status=status.HTTP_200_OK,
+ )
+ else:
+ return Response(
+ {"error": "Payment not completed."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ except Payment.DoesNotExist:
+ return Response(
+ {"error": "Payment not found."},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ @action(detail=False, methods=["get"], url_path="cancel")
+ def payment_cancel(self, request):
+ """Handle cancelled payment."""
+ return Response(
+ {
+ "message": "Payment cancelled. You can complete it later "
+ "(session expires in 24h)."
+ },
+ status=status.HTTP_200_OK,
+ )
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..4a8d9f1
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,14 @@
+Django==4.2.7
+djangorestframework==3.14.0
+djangorestframework-simplejwt==5.3.0
+drf-spectacular==0.26.5
+django-q==1.3.9
+python-dotenv==1.0.0
+stripe==7.4.0
+requests==2.31.0
+psycopg2-binary==2.9.11
+redis==3.5.3
+django-cors-headers==4.9.0
+PyJWT==2.10.1
+pytz==2025.2
+setuptools==69.0.0
diff --git a/users/__init__.py b/users/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/users/admin.py b/users/admin.py
new file mode 100644
index 0000000..32827a5
--- /dev/null
+++ b/users/admin.py
@@ -0,0 +1,43 @@
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
+
+from users.models import User
+
+
+@admin.register(User)
+class UserAdmin(BaseUserAdmin):
+ """Admin configuration for custom User model."""
+
+ ordering = ["id"]
+ list_display = ["email", "first_name", "last_name", "is_staff"]
+ fieldsets = (
+ (None, {"fields": ("email", "password")}),
+ ("Personal Info", {"fields": ("first_name", "last_name")}),
+ (
+ "Permissions",
+ {
+ "fields": (
+ "is_active",
+ "is_staff",
+ "is_superuser",
+ )
+ }
+ ),
+ ("Important dates", {"fields": ("last_login",)}),
+ )
+ add_fieldsets = (
+ (
+ None,
+ {
+ "classes": ("wide",),
+ "fields": (
+ "email",
+ "password1",
+ "password2",
+ "first_name",
+ "last_name",
+ "is_staff",
+ ),
+ },
+ ),
+ )
diff --git a/users/apps.py b/users/apps.py
new file mode 100644
index 0000000..e9121c9
--- /dev/null
+++ b/users/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class UsersConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "users"
diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py
new file mode 100644
index 0000000..3fa24b5
--- /dev/null
+++ b/users/migrations/0001_initial.py
@@ -0,0 +1,73 @@
+# Generated by Django 4.2.7 on 2025-11-05 11:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="User",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("password", models.CharField(max_length=128, verbose_name="password")),
+ (
+ "last_login",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="last login"
+ ),
+ ),
+ (
+ "is_superuser",
+ models.BooleanField(
+ default=False,
+ help_text="Designates that this user has all permissions without explicitly assigning them.",
+ verbose_name="superuser status",
+ ),
+ ),
+ ("email", models.EmailField(max_length=255, unique=True)),
+ ("first_name", models.CharField(max_length=255)),
+ ("last_name", models.CharField(max_length=255)),
+ ("is_active", models.BooleanField(default=True)),
+ ("is_staff", models.BooleanField(default=False)),
+ (
+ "groups",
+ models.ManyToManyField(
+ blank=True,
+ help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.group",
+ verbose_name="groups",
+ ),
+ ),
+ (
+ "user_permissions",
+ models.ManyToManyField(
+ blank=True,
+ help_text="Specific permissions for this user.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.permission",
+ verbose_name="user permissions",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/users/models.py b/users/models.py
new file mode 100644
index 0000000..97cf9a8
--- /dev/null
+++ b/users/models.py
@@ -0,0 +1,44 @@
+from django.contrib.auth.models import (
+ AbstractBaseUser,
+ BaseUserManager,
+ PermissionsMixin,
+)
+from django.db import models
+
+
+class UserManager(BaseUserManager):
+ """Manager for custom user model."""
+
+ def create_user(self, email, password=None, **extra_fields):
+ """Create, save and return a new user."""
+ if not email:
+ raise ValueError("User must have an email address")
+ user = self.model(email=self.normalize_email(email), **extra_fields)
+ user.set_password(password)
+ user.save(using=self._db)
+ return user
+
+ def create_superuser(self, email, password):
+ """Create and return a new superuser."""
+ user = self.create_user(email, password)
+ user.is_staff = True
+ user.is_superuser = True
+ user.save(using=self._db)
+ return user
+
+
+class User(AbstractBaseUser, PermissionsMixin):
+ """Custom user model that uses email instead of username."""
+
+ email = models.EmailField(max_length=255, unique=True)
+ first_name = models.CharField(max_length=255)
+ last_name = models.CharField(max_length=255)
+ is_active = models.BooleanField(default=True)
+ is_staff = models.BooleanField(default=False)
+
+ objects = UserManager()
+
+ USERNAME_FIELD = "email"
+
+ def __str__(self):
+ return self.email
diff --git a/users/serializers.py b/users/serializers.py
new file mode 100644
index 0000000..b0d96e4
--- /dev/null
+++ b/users/serializers.py
@@ -0,0 +1,35 @@
+from django.contrib.auth import get_user_model
+from rest_framework import serializers
+
+
+class UserSerializer(serializers.ModelSerializer):
+ """Serializer for the user object."""
+
+ class Meta:
+ model = get_user_model()
+ fields = ["id", "email", "password", "first_name", "last_name"]
+ read_only_fields = ["id"]
+ extra_kwargs = {"password": {"write_only": True, "min_length": 8}}
+
+ def create(self, validated_data):
+ """Create a new user with encrypted password and return it."""
+ return get_user_model().objects.create_user(**validated_data)
+
+ def update(self, instance, validated_data):
+ """Update a user, set password correctly and return it."""
+ password = validated_data.pop("password", None)
+ user = super().update(instance, validated_data)
+
+ if password:
+ user.set_password(password)
+ user.save()
+
+ return user
+
+
+class UserDetailSerializer(UserSerializer):
+ """Serializer for user detail view."""
+
+ class Meta(UserSerializer.Meta):
+ fields = UserSerializer.Meta.fields
+ read_only_fields = ["id", "email"]
diff --git a/users/tests.py b/users/tests.py
new file mode 100644
index 0000000..116c967
--- /dev/null
+++ b/users/tests.py
@@ -0,0 +1,50 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+from rest_framework import status
+from rest_framework.test import APIClient
+
+
+class UserTests(TestCase):
+ """Tests for User model and API."""
+
+ def setUp(self):
+ self.client = APIClient()
+ self.user_data = {
+ "email": "test@example.com",
+ "password": "testpass123",
+ "first_name": "Test",
+ "last_name": "User",
+ }
+
+ def test_create_user(self):
+ """Test creating a new user."""
+ url = reverse("users:register")
+ response = self.client.post(url, self.user_data)
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ self.assertIn("id", response.data)
+ self.assertEqual(response.data["email"], self.user_data["email"])
+
+ def test_token_generation(self):
+ """Test JWT token generation."""
+ user = get_user_model().objects.create_user(**self.user_data)
+ url = reverse("users:token")
+ response = self.client.post(
+ url, {"email": user.email, "password": self.user_data["password"]}
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertIn("access", response.data)
+ self.assertIn("refresh", response.data)
+
+ def test_retrieve_user_profile(self):
+ """Test retrieving authenticated user profile."""
+ user = get_user_model().objects.create_user(**self.user_data)
+ self.client.force_authenticate(user=user)
+
+ url = reverse("users:me")
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["email"], user.email)
diff --git a/users/urls.py b/users/urls.py
new file mode 100644
index 0000000..37a046d
--- /dev/null
+++ b/users/urls.py
@@ -0,0 +1,18 @@
+from django.urls import path
+from rest_framework_simplejwt.views import TokenRefreshView
+
+from users.views import (
+ CreateUserView,
+ CustomTokenObtainPairView,
+ ManageUserView,
+)
+
+
+urlpatterns = [
+ path("register/", CreateUserView.as_view(), name="register"),
+ path("token/", CustomTokenObtainPairView.as_view(), name="token"),
+ path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"),
+ path("me/", ManageUserView.as_view(), name="me"),
+]
+
+app_name = "users"
diff --git a/users/views.py b/users/views.py
new file mode 100644
index 0000000..fa75d78
--- /dev/null
+++ b/users/views.py
@@ -0,0 +1,28 @@
+from rest_framework import generics, permissions
+from rest_framework_simplejwt.views import TokenObtainPairView
+
+from users.serializers import UserDetailSerializer, UserSerializer
+
+
+class CreateUserView(generics.CreateAPIView):
+ """Create a new user in the system."""
+
+ serializer_class = UserSerializer
+ permission_classes = [permissions.AllowAny]
+
+
+class ManageUserView(generics.RetrieveUpdateAPIView):
+ """Manage the authenticated user."""
+
+ serializer_class = UserDetailSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_object(self):
+ """Retrieve and return authenticated user."""
+ return self.request.user
+
+
+class CustomTokenObtainPairView(TokenObtainPairView):
+ """Custom token view with proper header name."""
+
+ pass