diff --git a/.coveragerc b/.coveragerc index 89c86bc..1225483 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,5 +2,5 @@ omit = */tests/* */__init__.py - api/solomon/infrastructure/* - api/solomon/models.py + app/solomon/infrastructure/* + app/solomon/models.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6076725..2ab919d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r api/requirements.txt + pip install -r app/requirements.txt - name: Run migrations run: | diff --git a/Dockerfile b/Dockerfile index 1803ebd..d6bf44e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM python:3.11-slim-buster as base WORKDIR /app # Install dependencies -COPY api/requirements.txt . +COPY app/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy project files @@ -17,4 +17,4 @@ FROM base as production EXPOSE 8000 # Run the application -CMD ["uvicorn", "api.solomon.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "app.solomon.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 6286e45..007221a 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ migration: alembic revision --autogenerate -m "$(name)" run-local: - uvicorn api.solomon.main:app --reload + uvicorn app.solomon.main:app --reload test: pytest -vv $(file) --cov-report term-missing --cov=. --cov-config=.coveragerc diff --git a/alembic.ini b/alembic.ini index 7b041d1..63bfa35 100644 --- a/alembic.ini +++ b/alembic.ini @@ -2,7 +2,7 @@ [alembic] # path to migration scripts -script_location = api/solomon/migrations +script_location = app/solomon/migrations # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # Uncomment the line below if you want the files to be prepended with date and time diff --git a/api/solomon/models.py b/api/solomon/models.py deleted file mode 100644 index 13bdbdc..0000000 --- a/api/solomon/models.py +++ /dev/null @@ -1,2 +0,0 @@ -from api.solomon.users.domain.models import User # noqa -from api.solomon.transactions.domain.models import CreditCard, Category # noqa diff --git a/api/solomon/transactions/application/factories.py b/api/solomon/transactions/application/factories.py deleted file mode 100644 index 5de741d..0000000 --- a/api/solomon/transactions/application/factories.py +++ /dev/null @@ -1,28 +0,0 @@ -from fastapi import Depends - -from api.solomon.transactions.application.services import ( - CategoryService, - CreditCardService, -) -from api.solomon.transactions.infrastructure.factories import ( - get_category_repository, - get_credit_card_repository, -) -from api.solomon.transactions.infrastructure.repositories import ( - CategoryRepository, - CreditCardRepository, -) - - -def get_credit_card_service( - credit_card_repository: CreditCardRepository = Depends(get_credit_card_repository), -) -> CreditCardService: - """Factory for CreditCardService""" - return CreditCardService(credit_card_repository) - - -def get_category_service( - category_repository: CategoryRepository = Depends(get_category_repository), -) -> CategoryService: - """Factory for CreditCardService""" - return CategoryService(category_repository) diff --git a/api/solomon/transactions/domain/models.py b/api/solomon/transactions/domain/models.py deleted file mode 100644 index 9613341..0000000 --- a/api/solomon/transactions/domain/models.py +++ /dev/null @@ -1,26 +0,0 @@ -from uuid import uuid4 - -from sqlalchemy import Column, Float, ForeignKey, Integer, String -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import relationship - -from api.solomon.infrastructure.database import BaseModel - - -class CreditCard(BaseModel): - __tablename__ = "credit_cards" - - user_id = Column( - UUID(as_uuid=False), ForeignKey("users.id"), nullable=False, default=uuid4 - ) - user = relationship("User", back_populates="credit_cards") - - name = Column(String(50), nullable=False) - limit = Column(Float, nullable=False) - invoice_start_day = Column(Integer, nullable=False) - - -class Category(BaseModel): - __tablename__ = "categories" - - description = Column(String(30), nullable=False) diff --git a/api/solomon/transactions/infrastructure/factories.py b/api/solomon/transactions/infrastructure/factories.py deleted file mode 100644 index eae447b..0000000 --- a/api/solomon/transactions/infrastructure/factories.py +++ /dev/null @@ -1,8 +0,0 @@ -from api.solomon.infrastructure.database import get_repository -from api.solomon.transactions.infrastructure.repositories import ( - CategoryRepository, - CreditCardRepository, -) - -get_credit_card_repository = get_repository(CreditCardRepository) -get_category_repository = get_repository(CategoryRepository) diff --git a/api/solomon/transactions/presentation/models.py b/api/solomon/transactions/presentation/models.py deleted file mode 100644 index 6f71b4e..0000000 --- a/api/solomon/transactions/presentation/models.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel - - -class CreditCardCreate(BaseModel): - name: str - limit: float - invoice_start_day: int - - -class CreditCardUpdate(BaseModel): - name: Optional[str] = None - limit: Optional[float] = None - invoice_start_day: Optional[int] = None - - -class CreditCardCreatedResponse(CreditCardCreate): - id: str - - -class CreditCardUpdatedResponse(CreditCardCreatedResponse): - pass - - -class CategoryResponse(BaseModel): - id: str - description: str diff --git a/api/solomon/users/infrastructure/factories.py b/api/solomon/users/infrastructure/factories.py deleted file mode 100644 index 33dc5cb..0000000 --- a/api/solomon/users/infrastructure/factories.py +++ /dev/null @@ -1,4 +0,0 @@ -from api.solomon.infrastructure.database import get_repository -from api.solomon.users.infrastructure.repositories import UserRepository - -get_user_repository = get_repository(UserRepository) diff --git a/api/tests/solomon/transactions/application/test_transactions_services.py b/api/tests/solomon/transactions/application/test_transactions_services.py deleted file mode 100644 index 729114c..0000000 --- a/api/tests/solomon/transactions/application/test_transactions_services.py +++ /dev/null @@ -1,139 +0,0 @@ -import pytest - -from api.solomon.transactions.application.services import CreditCardService -from api.solomon.transactions.domain.exceptions import CreditCardNotFound -from api.tests.solomon.factories.credit_card_factory import CreditCardFactory - - -class TestCreditCardService: - def test_get_credit_card(self, credit_card_service, mock_repository): - mock_user_id = "123" - credit_card = CreditCardFactory.build() - mock_repository.get_by_id.return_value = credit_card - - result = credit_card_service.get_credit_card("credit_card_id", mock_user_id) - - mock_repository.get_by_id.assert_called_once_with( - id="credit_card_id", user_id=mock_user_id - ) - assert result == credit_card - - def test_get_invalid_credit_card(self, credit_card_service, mock_repository): - mock_user_id = "123" - mock_repository.get_by_id.return_value = None - - with pytest.raises(CreditCardNotFound): - credit_card_service.get_credit_card("invalid_id", mock_user_id) - - mock_repository.get_by_id.assert_called_once_with( - id="invalid_id", user_id=mock_user_id - ) - - def test_get_credit_cards(self, credit_card_service, mock_repository): - mock_user_id = "123" - mock_credit_cards = [CreditCardFactory.build(), CreditCardFactory.build()] - mock_repository.get_all.return_value = mock_credit_cards - - result = credit_card_service.get_credit_cards(mock_user_id) - - assert result == mock_credit_cards - assert isinstance(result, list) - assert len(result) == 2 - mock_repository.get_all.assert_called_once_with(user_id=mock_user_id) - - def test_create_credit_card(self, credit_card_service, mock_repository): - mock_credit_card = CreditCardFactory.build() - mock_repository.create.return_value = mock_credit_card - - result = credit_card_service.create_credit_card( - user_id=mock_credit_card.user_id, - name=mock_credit_card.name, - limit=mock_credit_card.limit, - invoice_start_day=mock_credit_card.invoice_start_day, - ) - - assert result == mock_credit_card - mock_repository.create.assert_called_once_with( - user_id=mock_credit_card.user_id, - name=mock_credit_card.name, - limit=mock_credit_card.limit, - invoice_start_day=mock_credit_card.invoice_start_day, - ) - - def test_create_invalid_credit_card(self, credit_card_service, mock_repository): - mock_credit_card = CreditCardFactory.build() - mock_repository.create.return_value = None - - result = credit_card_service.create_credit_card( - user_id=mock_credit_card.user_id, - name=mock_credit_card.name, - limit=mock_credit_card.limit, - invoice_start_day=mock_credit_card.invoice_start_day, - ) - - assert result is None - mock_repository.create.assert_called_once_with( - user_id=mock_credit_card.user_id, - name=mock_credit_card.name, - limit=mock_credit_card.limit, - invoice_start_day=mock_credit_card.invoice_start_day, - ) - - def test_update_credit_card(self, credit_card_service, mock_repository): - # Arrange - mock_credit_card = CreditCardFactory.build() - new_name = "New name" - mock_credit_card.name = new_name - - mock_repository.get_by_id.return_value = mock_credit_card - mock_repository.update.return_value = mock_credit_card - - credit_card_service = CreditCardService(mock_repository) - - # Act - updated_credit_card = credit_card_service.update_credit_card( - mock_credit_card, mock_credit_card.user_id, name=new_name - ) - - # Assert - assert updated_credit_card.name == new_name - mock_repository.update.assert_called_once_with(mock_credit_card, name=new_name) - - def test_update_credit_card_not_found(self, credit_card_service, mock_repository): - # Arrange - mock_credit_card = CreditCardFactory.build() - mock_credit_card.user_id = "test_user_id" - mock_repository.get_by_id.return_value = None - - # Act and Assert - with pytest.raises(CreditCardNotFound): - credit_card_service.update_credit_card( - mock_credit_card, mock_credit_card.user_id, name="New Name" - ) - - def test_delete_credit_card(self, credit_card_service, mock_repository): - # Arrange - mock_credit_card = CreditCardFactory.build() - mock_credit_card.user_id = "test_user_id" - mock_repository.get_by_id.return_value = mock_credit_card - - # Act - deleted_credit_card = credit_card_service.delete_credit_card( - mock_credit_card.id, mock_credit_card.user_id - ) - - # Assert - assert deleted_credit_card == mock_credit_card - mock_repository.delete.assert_called_once_with(credit_card=mock_credit_card) - - def test_delete_credit_card_not_found(self, credit_card_service, mock_repository): - # Arrange - mock_credit_card = CreditCardFactory.build() - mock_credit_card.user_id = "test_user_id" - mock_repository.get_by_id.return_value = None - - # Act and Assert - with pytest.raises(CreditCardNotFound): - credit_card_service.delete_credit_card( - mock_credit_card.id, mock_credit_card.user_id - ) diff --git a/api/tests/solomon/transactions/conftest.py b/api/tests/solomon/transactions/conftest.py deleted file mode 100644 index b60d7bc..0000000 --- a/api/tests/solomon/transactions/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -from unittest.mock import Mock - -import pytest - -from api.solomon.transactions.application.services import CreditCardService - - -@pytest.fixture -def mock_repository(): - return Mock() - - -@pytest.fixture -def credit_card_service(mock_repository): - return CreditCardService(credit_card_repository=mock_repository) diff --git a/api/__init__.py b/app/__init__.py similarity index 100% rename from api/__init__.py rename to app/__init__.py diff --git a/api/requirements.txt b/app/requirements.txt similarity index 100% rename from api/requirements.txt rename to app/requirements.txt diff --git a/api/solomon/auth/application/factories.py b/app/solomon/auth/application/dependencies.py similarity index 55% rename from api/solomon/auth/application/factories.py rename to app/solomon/auth/application/dependencies.py index 5cd1fa6..ce88e5c 100644 --- a/api/solomon/auth/application/factories.py +++ b/app/solomon/auth/application/dependencies.py @@ -1,8 +1,8 @@ from fastapi import Depends -from api.solomon.auth.application.services import AuthService -from api.solomon.users.infrastructure.factories import get_user_repository -from api.solomon.users.infrastructure.repositories import UserRepository +from app.solomon.auth.application.services import AuthService +from app.solomon.users.infrastructure.factories import get_user_repository +from app.solomon.users.infrastructure.repositories import UserRepository def get_auth_service( diff --git a/api/solomon/auth/application/security.py b/app/solomon/auth/application/security.py similarity index 88% rename from api/solomon/auth/application/security.py rename to app/solomon/auth/application/security.py index fb8bb0b..4d80959 100644 --- a/api/solomon/auth/application/security.py +++ b/app/solomon/auth/application/security.py @@ -2,20 +2,18 @@ import time from typing import Any +import bcrypt import jwt from fastapi import Depends, HTTPException from fastapi.security import HTTPBearer from jwt import PyJWTError -from passlib.context import CryptContext from starlette.status import HTTP_401_UNAUTHORIZED -from api.solomon.auth.domain.exceptions import ExpiredTokenError -from api.solomon.auth.presentation.models import UserTokenAuthenticated -from api.solomon.infrastructure.config import EXPIRES_AT, SECRET_KEY -from api.solomon.users.infrastructure.factories import get_user_repository -from api.solomon.users.infrastructure.repositories import UserRepository - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +from app.solomon.auth.domain.exceptions import ExpiredTokenError +from app.solomon.auth.presentation.models import UserTokenAuthenticated +from app.solomon.infrastructure.config import EXPIRES_AT, SECRET_KEY +from app.solomon.users.infrastructure.factories import get_user_repository +from app.solomon.users.infrastructure.repositories import UserRepository security = HTTPBearer() @@ -34,7 +32,8 @@ def generate_hashed_password(password: str) -> str: str The hashed password. """ - return pwd_context.hash(password) + hashed_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()) + return hashed_password.decode() def is_password_valid(plain_password: str, hashed_password: str) -> bool: @@ -53,7 +52,7 @@ def is_password_valid(plain_password: str, hashed_password: str) -> bool: bool True if the password is valid, False otherwise. """ - return pwd_context.verify(plain_password, hashed_password) + return bcrypt.checkpw(plain_password.encode(), hashed_password.encode()) def generate_token( diff --git a/api/solomon/auth/application/services.py b/app/solomon/auth/application/services.py similarity index 82% rename from api/solomon/auth/application/services.py rename to app/solomon/auth/application/services.py index af88845..9d85a4d 100644 --- a/api/solomon/auth/application/services.py +++ b/app/solomon/auth/application/services.py @@ -1,15 +1,15 @@ from fastapi.security import HTTPBearer -from api.solomon.auth.application.security import ( +from app.solomon.auth.application.security import ( generate_token, is_password_valid, ) -from api.solomon.auth.domain.exceptions import AuthenticationError -from api.solomon.auth.presentation.models import ( +from app.solomon.auth.domain.exceptions import AuthenticationError +from app.solomon.auth.presentation.models import ( LoginCreate, UserLoggedinResponse, ) -from api.solomon.users.infrastructure.repositories import UserRepository +from app.solomon.users.infrastructure.repositories import UserRepository security = HTTPBearer() diff --git a/api/solomon/auth/domain/exceptions.py b/app/solomon/auth/domain/exceptions.py similarity index 100% rename from api/solomon/auth/domain/exceptions.py rename to app/solomon/auth/domain/exceptions.py diff --git a/api/solomon/auth/presentation/models.py b/app/solomon/auth/presentation/models.py similarity index 100% rename from api/solomon/auth/presentation/models.py rename to app/solomon/auth/presentation/models.py diff --git a/api/solomon/auth/presentation/resources.py b/app/solomon/auth/presentation/resources.py similarity index 85% rename from api/solomon/auth/presentation/resources.py rename to app/solomon/auth/presentation/resources.py index 783dfa1..da913d4 100644 --- a/api/solomon/auth/presentation/resources.py +++ b/app/solomon/auth/presentation/resources.py @@ -1,19 +1,19 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status -from api.solomon.auth.application.factories import get_auth_service -from api.solomon.auth.application.security import get_current_user -from api.solomon.auth.application.services import AuthService -from api.solomon.auth.domain.exceptions import AuthenticationError -from api.solomon.auth.presentation.models import ( +from app.solomon.auth.application.dependencies import get_auth_service +from app.solomon.auth.application.security import get_current_user +from app.solomon.auth.application.services import AuthService +from app.solomon.auth.domain.exceptions import AuthenticationError +from app.solomon.auth.presentation.models import ( LoginCreate, UserCreate, UserCreateResponse, UserLoggedinResponse, UserTokenAuthenticated, ) -from api.solomon.users.application.factories import get_user_service -from api.solomon.users.application.services import UserService -from api.solomon.users.domain.exceptions import UserAlreadyExists +from app.solomon.users.application.factories import get_user_service +from app.solomon.users.application.services import UserService +from app.solomon.users.domain.exceptions import UserAlreadyExists router = APIRouter() diff --git a/api/solomon/infrastructure/config.py b/app/solomon/infrastructure/config.py similarity index 100% rename from api/solomon/infrastructure/config.py rename to app/solomon/infrastructure/config.py diff --git a/api/solomon/infrastructure/database.py b/app/solomon/infrastructure/database.py similarity index 95% rename from api/solomon/infrastructure/database.py rename to app/solomon/infrastructure/database.py index 98f6870..1fc3e97 100644 --- a/api/solomon/infrastructure/database.py +++ b/app/solomon/infrastructure/database.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import Session, declarative_base, sessionmaker -from api.solomon.infrastructure.config import DATABASE_URL +from app.solomon.infrastructure.config import DATABASE_URL Base = declarative_base() diff --git a/api/solomon/main.py b/app/solomon/main.py similarity index 57% rename from api/solomon/main.py rename to app/solomon/main.py index ac0f336..cfd787a 100644 --- a/api/solomon/main.py +++ b/app/solomon/main.py @@ -1,14 +1,14 @@ from fastapi import FastAPI from fastapi_sqlalchemy import DBSessionMiddleware -from api.solomon.infrastructure.config import DATABASE_URL -from api.solomon.routes.routes import init_routes +from app.solomon.infrastructure.config import DATABASE_URL +from app.solomon.routes.routes import init_routes app = FastAPI( title="Solomon API", version="0.1.0", - docs_url="/api/docs", - redoc_url="/api/redoc", + docs_url="/app/docs", + redoc_url="/app/redoc", ) init_routes(app) diff --git a/api/solomon/migrations/README b/app/solomon/migrations/README similarity index 100% rename from api/solomon/migrations/README rename to app/solomon/migrations/README diff --git a/api/solomon/migrations/env.py b/app/solomon/migrations/env.py similarity index 93% rename from api/solomon/migrations/env.py rename to app/solomon/migrations/env.py index 3f1dd58..dc7674c 100644 --- a/api/solomon/migrations/env.py +++ b/app/solomon/migrations/env.py @@ -3,9 +3,9 @@ from alembic import context from sqlalchemy import engine_from_config, pool -from api.solomon.infrastructure.config import DATABASE_URL -from api.solomon.infrastructure.database import BaseModel -from api.solomon.models import * # noqa +from app.solomon.infrastructure.config import DATABASE_URL +from app.solomon.infrastructure.database import BaseModel +from app.solomon.models import * # noqa # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/api/solomon/migrations/script.py.mako b/app/solomon/migrations/script.py.mako similarity index 100% rename from api/solomon/migrations/script.py.mako rename to app/solomon/migrations/script.py.mako diff --git a/api/solomon/migrations/versions/10664ad51224_create_credit_cards.py b/app/solomon/migrations/versions/10664ad51224_create_credit_cards.py similarity index 100% rename from api/solomon/migrations/versions/10664ad51224_create_credit_cards.py rename to app/solomon/migrations/versions/10664ad51224_create_credit_cards.py diff --git a/app/solomon/migrations/versions/3337b63612ed_create_transactions_tables.py b/app/solomon/migrations/versions/3337b63612ed_create_transactions_tables.py new file mode 100644 index 0000000..3088de4 --- /dev/null +++ b/app/solomon/migrations/versions/3337b63612ed_create_transactions_tables.py @@ -0,0 +1,83 @@ +"""create_transactions_tables + +Revision ID: 3337b63612ed +Revises: f78a84a1a142 +Create Date: 2024-01-25 18:13:15.592781 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "3337b63612ed" +down_revision: Union[str, None] = "f78a84a1a142" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "transactions", + sa.Column("description", sa.String(length=50), nullable=False), + sa.Column("amount", sa.Float(), nullable=False), + sa.Column("date", sa.Date(), nullable=True), + sa.Column("recurring_day", sa.Integer(), nullable=True), + sa.Column("is_fixed", sa.Boolean(), nullable=False), + sa.Column("is_revenue", sa.Boolean(), nullable=False), + sa.Column("kind", sa.String(length=20), nullable=False), + sa.Column("category_id", sa.UUID(as_uuid=False), nullable=True), + sa.Column("user_id", sa.UUID(as_uuid=False), nullable=False), + sa.Column("credit_card_id", sa.UUID(as_uuid=False), nullable=True), + sa.Column("id", sa.UUID(as_uuid=False), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["category_id"], + ["categories.id"], + ), + sa.ForeignKeyConstraint( + ["credit_card_id"], + ["credit_cards.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "installments", + sa.Column("transaction_id", sa.UUID(as_uuid=False), nullable=False), + sa.Column("installment_number", sa.Integer(), nullable=False), + sa.Column("amount", sa.Float(), nullable=False), + sa.Column("id", sa.UUID(as_uuid=False), nullable=False), + sa.Column("date", sa.Date(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["transaction_id"], + ["transactions.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("installments") + op.drop_table("transactions") + # ### end Alembic commands ### diff --git a/api/solomon/migrations/versions/c0da4441dcd7_initial.py b/app/solomon/migrations/versions/c0da4441dcd7_initial.py similarity index 100% rename from api/solomon/migrations/versions/c0da4441dcd7_initial.py rename to app/solomon/migrations/versions/c0da4441dcd7_initial.py diff --git a/api/solomon/migrations/versions/f78a84a1a142_create_categories.py b/app/solomon/migrations/versions/f78a84a1a142_create_categories.py similarity index 100% rename from api/solomon/migrations/versions/f78a84a1a142_create_categories.py rename to app/solomon/migrations/versions/f78a84a1a142_create_categories.py diff --git a/app/solomon/models.py b/app/solomon/models.py new file mode 100644 index 0000000..affcd5f --- /dev/null +++ b/app/solomon/models.py @@ -0,0 +1,7 @@ +# flake8: noqa +from app.solomon.users.domain.models import User # noqa +from app.solomon.transactions.domain.models import ( + CreditCard, + Category, + Transaction, +) diff --git a/api/solomon/routes/routes.py b/app/solomon/routes/routes.py similarity index 66% rename from api/solomon/routes/routes.py rename to app/solomon/routes/routes.py index 76016d3..d36a960 100644 --- a/api/solomon/routes/routes.py +++ b/app/solomon/routes/routes.py @@ -1,12 +1,15 @@ from fastapi import APIRouter, FastAPI, Response -from api.solomon.auth.presentation.resources import router as auth_router -from api.solomon.transactions.presentation.categories_resources import ( +from app.solomon.auth.presentation.resources import router as auth_router +from app.solomon.transactions.presentation.categories_resources import ( category_router, ) -from api.solomon.transactions.presentation.credit_cards_resources import ( +from app.solomon.transactions.presentation.credit_cards_resources import ( credit_card_router, ) +from app.solomon.transactions.presentation.transactions_resources import ( + transaction_router, +) router = APIRouter() @@ -33,3 +36,6 @@ def init_routes(app: FastAPI) -> None: credit_card_router, prefix="/credit-cards", tags=["credit-cards"] ) app.include_router(category_router, prefix="/categories", tags=["categories"]) + app.include_router( + transaction_router, prefix="/transactions", tags=["transactions"] + ) diff --git a/app/solomon/transactions/application/dependencies.py b/app/solomon/transactions/application/dependencies.py new file mode 100644 index 0000000..5393cb8 --- /dev/null +++ b/app/solomon/transactions/application/dependencies.py @@ -0,0 +1,38 @@ +from fastapi import Depends + +from app.solomon.infrastructure.database import get_repository +from app.solomon.transactions.application.services import ( + CategoryService, + CreditCardService, + TransactionService, +) +from app.solomon.transactions.infrastructure.repositories import ( + CategoryRepository, + CreditCardRepository, + TransactionRepository, +) + +get_credit_card_repository = get_repository(CreditCardRepository) +get_category_repository = get_repository(CategoryRepository) +get_transaction_repository = get_repository(TransactionRepository) + + +def get_credit_card_service( + credit_card_repository: CreditCardRepository = Depends(get_credit_card_repository), +) -> CreditCardService: + """Factory for CreditCardService""" + return CreditCardService(credit_card_repository) + + +def get_category_service( + category_repository: CategoryRepository = Depends(get_category_repository), +) -> CategoryService: + """Factory for CreditCardService""" + return CategoryService(category_repository) + + +def get_transaction_service( + transaction_repository: TransactionRepository = Depends(get_transaction_repository), +) -> TransactionService: + """Factory for TransactionService""" + return TransactionService(transaction_repository) diff --git a/app/solomon/transactions/application/handlers.py b/app/solomon/transactions/application/handlers.py new file mode 100644 index 0000000..07e9f88 --- /dev/null +++ b/app/solomon/transactions/application/handlers.py @@ -0,0 +1,120 @@ +import logging +from datetime import datetime +from typing import List + +from dateutil.relativedelta import relativedelta + +from app.solomon.transactions.domain.models import Installment, Transaction +from app.solomon.transactions.presentation.models import ( + InstallmentCreate, + TransactionCreate, +) + +logger = logging.getLogger(__name__) + + +class CreditCardTransactionHandler: + """Credit card transaction handler. It is used to create a transaction and its installments.""" + + def __init__(self, transaction_repository): + self.transaction_repository = transaction_repository + + def process_transaction(self, transaction: TransactionCreate) -> Transaction: + """ + Create a transaction and its installments + + If a transaction has installments, it will create the installments based on the + number of installments and the transaction amount. The installments will be + created with the same amount and with the same day of the month as the original + transaction. + + Parameters + ---------- + transaction : TransactionCreate + Transaction to be created + + Returns + ------- + Transaction + Created transaction + """ + try: + installments = InstallmentHandler.generate_installments(transaction) + transaction_model = self._map_transaction_to_domain(transaction) + installments_models = self._map_installments_to_domain(installments) + + response = self.transaction_repository.create_with_installments( + transaction=transaction_model, installments=installments_models + ) + + return response + except Exception as e: + self.transaction_repository.rollback() + logger.error(e) + raise + + def _map_transaction_to_domain(self, transaction: TransactionCreate) -> Transaction: + return Transaction( + **transaction.model_dump(exclude_none=True, exclude=["installments_number"]) + ) + + def _map_installments_to_domain( + self, installments: List[InstallmentCreate] + ) -> List[Installment]: + return [Installment(**installment.model_dump()) for installment in installments] + + +class InstallmentHandler: + """Installment handler. It is used to generate installments for a transaction.""" + + @classmethod + def generate_installments( + cls, transaction: TransactionCreate + ) -> List[InstallmentCreate]: + """ + Generate installments for a transaction. + + Parameters + ---------- + transaction : TransactionCreate + The transaction for which installments need to be generated. + + Returns + ------- + List[InstallmentCreate] + A list of InstallmentCreate objects representing the generated installments. + """ + + if not transaction.installments_number: + transaction.installments_number = 1 + + installments_amount = round( + transaction.amount / transaction.installments_number, 2 + ) + + installments_dates = cls._generate_installment_dates( + transaction.date, transaction.installments_number + ) + + installments = [ + InstallmentCreate( + amount=installments_amount, + installment_number=i + 1, + date=date, + ) + for i, date in enumerate(installments_dates) + ] + + return installments + + @classmethod + def _generate_installment_dates( + cls, start_date: datetime, num_installments: int + ) -> List[datetime]: + dates = [start_date] + + for _ in range(1, num_installments): + next_month = dates[-1] + relativedelta(months=1) + dates.append(next_month) + + return dates diff --git a/api/solomon/transactions/application/services.py b/app/solomon/transactions/application/services.py similarity index 66% rename from api/solomon/transactions/application/services.py rename to app/solomon/transactions/application/services.py index ab3acde..d627635 100644 --- a/api/solomon/transactions/application/services.py +++ b/app/solomon/transactions/application/services.py @@ -1,15 +1,28 @@ +import logging from typing import List -from api.solomon.transactions.domain.exceptions import ( +from app.solomon.transactions.application.handlers import CreditCardTransactionHandler +from app.solomon.transactions.domain.exceptions import ( CategoryNotFound, CreditCardNotFound, ) -from api.solomon.transactions.domain.models import Category, CreditCard -from api.solomon.transactions.infrastructure.repositories import ( +from app.solomon.transactions.domain.models import ( + Category, + CreditCard, +) +from app.solomon.transactions.domain.options import Kinds +from app.solomon.transactions.infrastructure.repositories import ( CategoryRepository, CreditCardRepository, + TransactionRepository, +) +from app.solomon.transactions.presentation.models import ( + Transaction, + TransactionCreate, ) +logger = logging.getLogger(__name__) + class CreditCardService: """Service for handling CreditCard business logic.""" @@ -140,3 +153,45 @@ def get_category(self, id: str) -> Category: raise CategoryNotFound("Category not found.") return category + + +class TransactionService: + def __init__(self, transaction_repository: TransactionRepository) -> None: + self.transaction_repository = transaction_repository + + def create_transaction(self, transaction: TransactionCreate) -> Transaction: + """ + Create a new transaction. + + Parameters + ---------- + transaction : TransactionCreate + The transaction data to create. + + Returns + ------- + Transaction + The created transaction. + + Raises + ------ + ValueError + If there are validation errors in the transaction data. + Exception + If any other error occurs. + """ + created_transaction = self._handle_transaction(transaction) + return created_transaction + + def _handle_transaction(self, transaction: TransactionCreate) -> Transaction: + if transaction.kind == Kinds.CREDIT and not transaction.is_fixed: + handler = CreditCardTransactionHandler(self.transaction_repository) + return handler.process_transaction(transaction) + + created_transaction = self.transaction_repository.create( + **transaction.model_dump(exclude_none=True) + ) + + response = Transaction.model_validate(created_transaction) + + return response diff --git a/api/solomon/transactions/domain/exceptions.py b/app/solomon/transactions/domain/exceptions.py similarity index 100% rename from api/solomon/transactions/domain/exceptions.py rename to app/solomon/transactions/domain/exceptions.py diff --git a/app/solomon/transactions/domain/models.py b/app/solomon/transactions/domain/models.py new file mode 100644 index 0000000..507f07d --- /dev/null +++ b/app/solomon/transactions/domain/models.py @@ -0,0 +1,74 @@ +from uuid import uuid4 + +from sqlalchemy import Boolean, Column, Date, Float, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.solomon.infrastructure.database import BaseModel + + +class CreditCard(BaseModel): + """Credit card model""" + + __tablename__ = "credit_cards" + + user_id = Column( + UUID(as_uuid=False), ForeignKey("users.id"), nullable=False, default=uuid4 + ) + user = relationship("User", back_populates="credit_cards") + + name = Column(String(50), nullable=False) + limit = Column(Float, nullable=False) + invoice_start_day = Column(Integer, nullable=False) + transactions = relationship("Transaction", back_populates="credit_card") + + +class Category(BaseModel): + """Category model""" + + __tablename__ = "categories" + + description = Column(String(30), nullable=False) + transactions = relationship("Transaction", back_populates="category") + + +class Transaction(BaseModel): + """Transaction model""" + + __tablename__ = "transactions" + + description = Column(String(50), nullable=False) + amount = Column(Float, nullable=False) + date = Column(Date, nullable=True) + recurring_day = Column(Integer, nullable=True) + is_fixed = Column(Boolean, nullable=False, default=False) + is_revenue = Column(Boolean, nullable=False, default=False) + kind = Column(String(20), nullable=False) + + installments = relationship("Installment", back_populates="transaction") + user = relationship("User", back_populates="transactions") + credit_card = relationship("CreditCard", back_populates="transactions") + category = relationship("Category", back_populates="transactions") + + category_id = Column(UUID(as_uuid=False), ForeignKey("categories.id")) + user_id = Column( + UUID(as_uuid=False), ForeignKey("users.id"), nullable=False, default=uuid4 + ) + credit_card_id = Column( + UUID(as_uuid=False), ForeignKey("credit_cards.id"), nullable=True + ) + + +class Installment(BaseModel): + """Installment model""" + + __tablename__ = "installments" + + date = Column(Date, nullable=False) + installment_number = Column(Integer, nullable=False) + amount = Column(Float, nullable=False) + + transaction_id = Column( + UUID(as_uuid=False), ForeignKey("transactions.id"), nullable=False + ) + transaction = relationship("Transaction", back_populates="installments") diff --git a/app/solomon/transactions/domain/options.py b/app/solomon/transactions/domain/options.py new file mode 100644 index 0000000..c14cf14 --- /dev/null +++ b/app/solomon/transactions/domain/options.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class Kinds(str, Enum): + CREDIT = "credit" + DEBIT = "debit" + TRANSFER = "transfer" + PIX = "pix" + CASH = "cash" diff --git a/api/solomon/transactions/infrastructure/repositories.py b/app/solomon/transactions/infrastructure/repositories.py similarity index 63% rename from api/solomon/transactions/infrastructure/repositories.py rename to app/solomon/transactions/infrastructure/repositories.py index 7c86f23..b8dcdc6 100644 --- a/api/solomon/transactions/infrastructure/repositories.py +++ b/app/solomon/transactions/infrastructure/repositories.py @@ -1,6 +1,26 @@ from typing import List -from api.solomon.transactions.domain.models import Category, CreditCard +from app.solomon.transactions.domain.models import ( + Category, + CreditCard, + Installment, + Transaction, +) + + +class CategoryRepository: + """Categories repository. It is used to interact with the database.""" + + def __init__(self, session): + self.session = session + + def get_all(self) -> List[Category]: + """Get all Credit Cards.""" + return self.session.query(Category).all() + + def get_by_id(self, id: str) -> Category | None: + """Get a Credit Card by id.""" + return self.session.query(Category).filter_by(id=id).first() class CreditCardRepository: @@ -46,16 +66,33 @@ def delete(self, credit_card: CreditCard) -> CreditCard: return credit_card -class CategoryRepository: - """Categories repository. It is used to interact with the database.""" +class TransactionRepository: + """Transactions repository. It is used to interact with the database.""" def __init__(self, session): self.session = session - def get_all(self) -> List[Category]: - """Get all Credit Cards.""" - return self.session.query(Category).all() + def commit(self): + """Commit the current transaction.""" + self.session.commit() - def get_by_id(self, id: str) -> Category | None: - """Get a Credit Card by id.""" - return self.session.query(Category).filter_by(id=id).first() + def rollback(self): + """Rollback the current transaction.""" + self.session.rollback() + + def create(self, **kwargs) -> Transaction: + """Create a new Transaction.""" + instance = Transaction(**kwargs) + self.session.add(instance) + self.commit() + return instance + + def create_with_installments( + self, transaction: Transaction, installments: List[Installment] + ) -> Transaction: + """Create a new Transaction along with its associated Installments.""" + transaction.installments = installments + + self.session.add(transaction) + self.session.commit() + return transaction diff --git a/api/solomon/transactions/presentation/categories_resources.py b/app/solomon/transactions/presentation/categories_resources.py similarity index 77% rename from api/solomon/transactions/presentation/categories_resources.py rename to app/solomon/transactions/presentation/categories_resources.py index ce26795..e81bf28 100644 --- a/api/solomon/transactions/presentation/categories_resources.py +++ b/app/solomon/transactions/presentation/categories_resources.py @@ -3,19 +3,19 @@ from fastapi import APIRouter, Depends, HTTPException, Response from starlette import status -from api.solomon.transactions.application.factories import ( +from app.solomon.transactions.application.dependencies import ( get_category_service, ) -from api.solomon.transactions.application.services import ( +from app.solomon.transactions.application.services import ( CategoryService, ) -from api.solomon.transactions.domain.exceptions import CategoryNotFound -from api.solomon.transactions.presentation.models import CategoryResponse +from app.solomon.transactions.domain.exceptions import CategoryNotFound +from app.solomon.transactions.presentation.models import Category category_router = APIRouter() -@category_router.get("/", response_model=List[CategoryResponse]) +@category_router.get("/", response_model=List[Category]) async def get_all_categories( category_service: CategoryService = Depends(get_category_service), ) -> Response: @@ -35,7 +35,7 @@ async def get_all_categories( return category_service.get_categories() -@category_router.get("/{category_id}", response_model=CategoryResponse) +@category_router.get("/{category_id}", response_model=Category) async def get_category( category_id: str, category_service: CategoryService = Depends(get_category_service), diff --git a/api/solomon/transactions/presentation/credit_cards_resources.py b/app/solomon/transactions/presentation/credit_cards_resources.py similarity index 88% rename from api/solomon/transactions/presentation/credit_cards_resources.py rename to app/solomon/transactions/presentation/credit_cards_resources.py index 5ff24a1..132987c 100644 --- a/api/solomon/transactions/presentation/credit_cards_resources.py +++ b/app/solomon/transactions/presentation/credit_cards_resources.py @@ -3,27 +3,26 @@ from fastapi import APIRouter, Depends, HTTPException, Response from starlette import status -from api.solomon.auth.application.security import get_current_user -from api.solomon.auth.presentation.models import ( +from app.solomon.auth.application.security import get_current_user +from app.solomon.auth.presentation.models import ( UserTokenAuthenticated, ) -from api.solomon.transactions.application.factories import get_credit_card_service -from api.solomon.transactions.application.services import ( +from app.solomon.transactions.application.dependencies import get_credit_card_service +from app.solomon.transactions.application.services import ( CreditCardService, ) -from api.solomon.transactions.domain.exceptions import CreditCardNotFound -from api.solomon.transactions.presentation.models import ( +from app.solomon.transactions.domain.exceptions import CreditCardNotFound +from app.solomon.transactions.presentation.models import ( + CreditCard, CreditCardCreate, - CreditCardCreatedResponse, CreditCardUpdate, - CreditCardUpdatedResponse, ) credit_card_router = APIRouter() @credit_card_router.post( - "/", response_model=CreditCardCreatedResponse, status_code=status.HTTP_201_CREATED + "/", response_model=CreditCard, status_code=status.HTTP_201_CREATED ) async def create_credit_card( credit_card: CreditCardCreate, @@ -49,11 +48,13 @@ async def create_credit_card( JSONResponse The created credit card with a 201 status code. """ + print(credit_card) + credit_card_created = credit_card_service.create_credit_card( **credit_card.model_dump(), user_id=current_user.id ) - return CreditCardCreatedResponse( + return CreditCard( name=credit_card_created.name, limit=credit_card_created.limit, invoice_start_day=credit_card_created.invoice_start_day, @@ -61,7 +62,7 @@ async def create_credit_card( ) -@credit_card_router.get("/", response_model=List[CreditCardCreatedResponse]) +@credit_card_router.get("/", response_model=List[CreditCard]) async def get_all_credit_cards( credit_card_service: CreditCardService = Depends(get_credit_card_service), current_user: UserTokenAuthenticated = Depends(get_current_user), @@ -87,7 +88,7 @@ async def get_all_credit_cards( return credit_cards -@credit_card_router.get("/{credit_card_id}", response_model=CreditCardCreatedResponse) +@credit_card_router.get("/{credit_card_id}", response_model=CreditCard) async def get_credit_card( credit_card_id: str, credit_card_service: CreditCardService = Depends(get_credit_card_service), @@ -120,7 +121,7 @@ async def get_credit_card( @credit_card_router.delete( "/{credit_card_id}", - response_model=CreditCardCreatedResponse, + response_model=CreditCard, status_code=status.HTTP_200_OK, ) async def delete_credit_card( @@ -158,7 +159,7 @@ async def delete_credit_card( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) -@credit_card_router.put("/{credit_card_id}", response_model=CreditCardUpdatedResponse) +@credit_card_router.put("/{credit_card_id}", response_model=CreditCard) async def update_credit_card( credit_card_id: str, credit_card_update: CreditCardUpdate, diff --git a/app/solomon/transactions/presentation/models.py b/app/solomon/transactions/presentation/models.py new file mode 100644 index 0000000..01f6984 --- /dev/null +++ b/app/solomon/transactions/presentation/models.py @@ -0,0 +1,125 @@ +import datetime +from typing import List, Optional + +from pydantic import BaseModel, PositiveInt, validator + +from app.solomon.transactions.domain.options import Kinds + + +class CreditCardBase(BaseModel): + """Base model for credit card""" + + name: str + limit: float + invoice_start_day: int + + +class CreditCardCreate(CreditCardBase): + """Request model for credit card creation""" + + pass + + +class CreditCardUpdate(BaseModel): + """Request model for credit card update""" + + name: Optional[str] = None + limit: Optional[float] = None + invoice_start_day: Optional[PositiveInt] = None + + +class CreditCard(CreditCardBase): + """Credit card model""" + + id: str + + class Config: + from_attributes = True + + +class Category(BaseModel): + """Response model for categories""" + + id: str + description: str + + class Config: + from_attributes = True + + +class InstallmentBase(BaseModel): + """Base model for installments""" + + installment_number: int + date: datetime.date + amount: float + + +class InstallmentCreate(InstallmentBase): + """Request model for creating installments""" + + pass + + +class Installment(InstallmentBase): + """Response model for installments""" + + id: str + + class Config: + from_attributes = True + + +class TransactionBase(BaseModel): + """Base model for transactions""" + + description: str + amount: float + is_fixed: bool + is_revenue: bool + date: Optional[datetime.date] = None + recurring_day: Optional[PositiveInt] = None + kind: Kinds + category_id: str + user_id: Optional[str] = None + credit_card_id: Optional[str] = None + + +class TransactionCreate(TransactionBase): + """Request model for creating a transaction""" + + installments_number: Optional[PositiveInt] = None + + @validator("recurring_day", always=True) + def validate_recurring_day(cls, recurring_day, values): + is_fixed = values.get("is_fixed") + if is_fixed and recurring_day is None: + raise ValueError("recurring day is required when transaction is fixed") + return recurring_day + + @validator("date", always=True) + def validate_date(cls, date, values): + is_fixed = values.get("is_fixed") + if not is_fixed and date is None: + raise ValueError("date is required when transaction is not fixed") + return date + + @validator("credit_card_id", always=True) + def validate_credit_card(cls, credit_card_id, values): + kind = values.get("kind") + if kind == Kinds.CREDIT and credit_card_id is None: + raise ValueError("credit card is required when transaction is credit") + elif kind != Kinds.CREDIT: + credit_card_id = None + + return credit_card_id + + +class Transaction(TransactionBase): + """Response model for transactions""" + + id: str + installments: Optional[List[Installment]] = None + + class Config: + from_attributes = True diff --git a/app/solomon/transactions/presentation/transactions_resources.py b/app/solomon/transactions/presentation/transactions_resources.py new file mode 100644 index 0000000..f05ba78 --- /dev/null +++ b/app/solomon/transactions/presentation/transactions_resources.py @@ -0,0 +1,53 @@ +from fastapi import Depends +from fastapi.responses import Response +from fastapi.routing import APIRouter +from starlette import status + +from app.solomon.auth.application.security import get_current_user +from app.solomon.auth.presentation.models import ( + UserTokenAuthenticated, +) +from app.solomon.transactions.application.dependencies import get_transaction_service +from app.solomon.transactions.application.services import TransactionService +from app.solomon.transactions.presentation.models import ( + Transaction, + TransactionCreate, +) + +transaction_router = APIRouter() + + +@transaction_router.post( + "/", response_model=Transaction, status_code=status.HTTP_201_CREATED +) +async def create_transaction( + transaction: TransactionCreate, + transaction_service: TransactionService = Depends(get_transaction_service), + current_user: UserTokenAuthenticated = Depends(get_current_user), +) -> Response: + """ + Create a new transaction. + + This function receives a TransactionCreate object and a TransactionService instance, + then tries to create a new transaction using the provided service. + + Parameters + ---------- + transaction : TransactionCreate + The transaction to be created. + transaction_service : TransactionService, optional + The service to be used to create the transaction, by default + Depends(get_transaction_service) + + Returns + ------- + JSONResponse + The created transaction with a 201 status code. + """ + try: + transaction = transaction.model_copy(update={"user_id": current_user.id}) + return transaction_service.create_transaction(transaction) + except Exception as e: + return Response( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=str(e) + ) diff --git a/api/solomon/users/application/factories.py b/app/solomon/users/application/factories.py similarity index 54% rename from api/solomon/users/application/factories.py rename to app/solomon/users/application/factories.py index 8a92732..f77c0bd 100644 --- a/api/solomon/users/application/factories.py +++ b/app/solomon/users/application/factories.py @@ -1,8 +1,8 @@ from fastapi import Depends -from api.solomon.users.application.services import UserService -from api.solomon.users.infrastructure.factories import get_user_repository -from api.solomon.users.infrastructure.repositories import UserRepository +from app.solomon.users.application.services import UserService +from app.solomon.users.infrastructure.factories import get_user_repository +from app.solomon.users.infrastructure.repositories import UserRepository def get_user_service( diff --git a/api/solomon/users/application/services.py b/app/solomon/users/application/services.py similarity index 81% rename from api/solomon/users/application/services.py rename to app/solomon/users/application/services.py index 66c0d55..b1cf0c6 100644 --- a/api/solomon/users/application/services.py +++ b/app/solomon/users/application/services.py @@ -1,7 +1,7 @@ -from api.solomon.auth.application.security import generate_hashed_password -from api.solomon.auth.presentation.models import UserCreate, UserCreateResponse -from api.solomon.users.domain.exceptions import UserAlreadyExists -from api.solomon.users.infrastructure.repositories import UserRepository +from app.solomon.auth.application.security import generate_hashed_password +from app.solomon.auth.presentation.models import UserCreate, UserCreateResponse +from app.solomon.users.domain.exceptions import UserAlreadyExists +from app.solomon.users.infrastructure.repositories import UserRepository class UserService: diff --git a/api/solomon/users/domain/exceptions.py b/app/solomon/users/domain/exceptions.py similarity index 100% rename from api/solomon/users/domain/exceptions.py rename to app/solomon/users/domain/exceptions.py diff --git a/api/solomon/users/domain/models.py b/app/solomon/users/domain/models.py similarity index 74% rename from api/solomon/users/domain/models.py rename to app/solomon/users/domain/models.py index 15becab..71792f1 100644 --- a/api/solomon/users/domain/models.py +++ b/app/solomon/users/domain/models.py @@ -1,7 +1,7 @@ from sqlalchemy import Column, String from sqlalchemy.orm import relationship -from api.solomon.infrastructure.database import BaseModel +from app.solomon.infrastructure.database import BaseModel class User(BaseModel): @@ -12,3 +12,4 @@ class User(BaseModel): hashed_password = Column(String, nullable=False) credit_cards = relationship("CreditCard", back_populates="user") + transactions = relationship("Transaction", back_populates="user") diff --git a/app/solomon/users/infrastructure/factories.py b/app/solomon/users/infrastructure/factories.py new file mode 100644 index 0000000..a549e21 --- /dev/null +++ b/app/solomon/users/infrastructure/factories.py @@ -0,0 +1,4 @@ +from app.solomon.infrastructure.database import get_repository +from app.solomon.users.infrastructure.repositories import UserRepository + +get_user_repository = get_repository(UserRepository) diff --git a/api/solomon/users/infrastructure/repositories.py b/app/solomon/users/infrastructure/repositories.py similarity index 94% rename from api/solomon/users/infrastructure/repositories.py rename to app/solomon/users/infrastructure/repositories.py index fc4e43c..5d5da39 100644 --- a/api/solomon/users/infrastructure/repositories.py +++ b/app/solomon/users/infrastructure/repositories.py @@ -1,4 +1,4 @@ -from api.solomon.users.domain.models import User +from app.solomon.users.domain.models import User class UserRepository: diff --git a/api/tests/__init__.py b/app/tests/__init__.py similarity index 100% rename from api/tests/__init__.py rename to app/tests/__init__.py diff --git a/api/tests/solomon/__init__.py b/app/tests/solomon/__init__.py similarity index 100% rename from api/tests/solomon/__init__.py rename to app/tests/solomon/__init__.py diff --git a/api/tests/solomon/auth/application/test_auth_security.py b/app/tests/solomon/auth/application/test_auth_security.py similarity index 85% rename from api/tests/solomon/auth/application/test_auth_security.py rename to app/tests/solomon/auth/application/test_auth_security.py index f664669..3a22c9a 100644 --- a/api/tests/solomon/auth/application/test_auth_security.py +++ b/app/tests/solomon/auth/application/test_auth_security.py @@ -5,7 +5,7 @@ from jwt import PyJWTError from starlette.status import HTTP_401_UNAUTHORIZED -from api.solomon.auth.application.security import ( +from app.solomon.auth.application.security import ( generate_hashed_password, generate_token, get_current_user, @@ -13,8 +13,8 @@ is_token_expired, verify_token, ) -from api.solomon.auth.domain.exceptions import ExpiredTokenError -from api.solomon.auth.presentation.models import UserTokenAuthenticated +from app.solomon.auth.domain.exceptions import ExpiredTokenError +from app.solomon.auth.presentation.models import UserTokenAuthenticated def test_generate_hashed_password(): @@ -66,8 +66,8 @@ def test_verify_token_invalid(): @pytest.mark.asyncio -@mock.patch("api.solomon.auth.application.security.verify_token") -@mock.patch("api.solomon.auth.application.security.get_user_repository") +@mock.patch("app.solomon.auth.application.security.verify_token") +@mock.patch("app.solomon.auth.application.security.get_user_repository") async def test_get_current_user(mock_get_user_repository, mock_verify_token): mock_user_repository = mock.Mock() mock_get_user_repository.return_value = mock_user_repository @@ -87,8 +87,8 @@ async def test_get_current_user(mock_get_user_repository, mock_verify_token): @pytest.mark.asyncio -@mock.patch("api.solomon.auth.application.security.verify_token") -@mock.patch("api.solomon.auth.application.security.get_user_repository") +@mock.patch("app.solomon.auth.application.security.verify_token") +@mock.patch("app.solomon.auth.application.security.get_user_repository") async def test_get_current_user_not_found(mock_get_user_repository, mock_verify_token): mock_user_repository = mock.Mock() mock_get_user_repository.return_value = mock_user_repository @@ -104,8 +104,8 @@ async def test_get_current_user_not_found(mock_get_user_repository, mock_verify_ @pytest.mark.asyncio -@mock.patch("api.solomon.auth.application.security.verify_token") -@mock.patch("api.solomon.auth.application.security.get_user_repository") +@mock.patch("app.solomon.auth.application.security.verify_token") +@mock.patch("app.solomon.auth.application.security.get_user_repository") async def test_get_current_user_invalid_token( mock_get_user_repository, mock_verify_token ): @@ -120,8 +120,8 @@ async def test_get_current_user_invalid_token( @pytest.mark.asyncio -@mock.patch("api.solomon.auth.application.security.verify_token") -@mock.patch("api.solomon.auth.application.security.get_user_repository") +@mock.patch("app.solomon.auth.application.security.verify_token") +@mock.patch("app.solomon.auth.application.security.get_user_repository") async def test_get_current_user_invalid_pyjwt_token( mock_get_user_repository, mock_verify_token ): diff --git a/api/tests/solomon/auth/application/test_auth_services.py b/app/tests/solomon/auth/application/test_auth_services.py similarity index 76% rename from api/tests/solomon/auth/application/test_auth_services.py rename to app/tests/solomon/auth/application/test_auth_services.py index e9e69e2..22d479a 100644 --- a/api/tests/solomon/auth/application/test_auth_services.py +++ b/app/tests/solomon/auth/application/test_auth_services.py @@ -2,11 +2,11 @@ import pytest -from api.solomon.auth.application.security import generate_hashed_password -from api.solomon.auth.application.services import AuthService -from api.solomon.auth.domain.exceptions import AuthenticationError -from api.solomon.auth.presentation.models import LoginCreate, UserLoggedinResponse -from api.solomon.users.infrastructure.repositories import UserRepository +from app.solomon.auth.application.security import generate_hashed_password +from app.solomon.auth.application.services import AuthService +from app.solomon.auth.domain.exceptions import AuthenticationError +from app.solomon.auth.presentation.models import LoginCreate, UserLoggedinResponse +from app.solomon.users.infrastructure.repositories import UserRepository def test_authenticate_success(): @@ -30,7 +30,7 @@ def test_authenticate_success(): @patch( - "api.solomon.auth.application.services.generate_token", return_value="test_token" + "app.solomon.auth.application.services.generate_token", return_value="test_token" ) def test_authenticate_invalid_credentials(mock_generate_token): # Arrange diff --git a/api/tests/solomon/auth/presentation/test_auth_resources.py b/app/tests/solomon/auth/presentation/test_auth_resources.py similarity index 93% rename from api/tests/solomon/auth/presentation/test_auth_resources.py rename to app/tests/solomon/auth/presentation/test_auth_resources.py index 16ee1ce..d0ddf63 100644 --- a/api/tests/solomon/auth/presentation/test_auth_resources.py +++ b/app/tests/solomon/auth/presentation/test_auth_resources.py @@ -2,10 +2,10 @@ from fastapi_sqlalchemy import db -from api.solomon.auth.application.factories import get_auth_service -from api.solomon.auth.application.security import generate_hashed_password -from api.solomon.auth.presentation.models import UserCreate -from api.solomon.users.application.factories import get_user_service +from app.solomon.auth.application.dependencies import get_auth_service +from app.solomon.auth.application.security import generate_hashed_password +from app.solomon.auth.presentation.models import UserCreate +from app.solomon.users.application.factories import get_user_service def test_register_user(client): @@ -99,7 +99,7 @@ def test_authenticate_user_with_invalid_username_or_password(client, user_factor def test_register_exception(client): - from api.solomon.main import app + from app.solomon.main import app user = UserCreate( username="John Doe", email="jhon.doe@example.com", password="123456" @@ -119,7 +119,7 @@ def test_register_exception(client): def test_login_exception(client): - from api.solomon.main import app + from app.solomon.main import app user = UserCreate( username="John Doe", email="jhon.doe@example.com", password="123456" diff --git a/api/tests/solomon/conftest.py b/app/tests/solomon/conftest.py similarity index 79% rename from api/tests/solomon/conftest.py rename to app/tests/solomon/conftest.py index aa178ad..5efc87e 100644 --- a/api/tests/solomon/conftest.py +++ b/app/tests/solomon/conftest.py @@ -6,14 +6,15 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker -from api.solomon.auth.application.security import generate_hashed_password, verify_token -from api.solomon.infrastructure.config import DATABASE_URL -from api.solomon.infrastructure.database import Base, get_db_session -from api.solomon.main import app -from api.solomon.users.domain.models import User -from api.tests.solomon.factories.category_factory import CategoryFactory -from api.tests.solomon.factories.credit_card_factory import CreditCardFactory -from api.tests.solomon.factories.user_factory import UserFactory +from app.solomon.auth.application.security import generate_hashed_password, verify_token +from app.solomon.infrastructure.config import DATABASE_URL +from app.solomon.infrastructure.database import Base, get_db_session +from app.solomon.main import app +from app.solomon.users.domain.models import User +from app.tests.solomon.factories.category_factory import CategoryFactory +from app.tests.solomon.factories.credit_card_factory import CreditCardFactory +from app.tests.solomon.factories.transaction_factory import TransactionCreateFactory +from app.tests.solomon.factories.user_factory import UserFactory engine = create_engine(DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -94,3 +95,4 @@ def current_user(current_user_token) -> User: register(UserFactory) register(CreditCardFactory) register(CategoryFactory) +register(TransactionCreateFactory) diff --git a/api/tests/solomon/factories/base_factory.py b/app/tests/solomon/factories/base_factory.py similarity index 100% rename from api/tests/solomon/factories/base_factory.py rename to app/tests/solomon/factories/base_factory.py diff --git a/api/tests/solomon/factories/category_factory.py b/app/tests/solomon/factories/category_factory.py similarity index 63% rename from api/tests/solomon/factories/category_factory.py rename to app/tests/solomon/factories/category_factory.py index c2c1832..5a89627 100644 --- a/api/tests/solomon/factories/category_factory.py +++ b/app/tests/solomon/factories/category_factory.py @@ -1,7 +1,7 @@ from factory import Faker -from api.solomon.transactions.domain.models import Category -from api.tests.solomon.factories.base_factory import BaseFactory +from app.solomon.transactions.domain.models import Category +from app.tests.solomon.factories.base_factory import BaseFactory class CategoryFactory(BaseFactory): diff --git a/api/tests/solomon/factories/credit_card_factory.py b/app/tests/solomon/factories/credit_card_factory.py similarity index 69% rename from api/tests/solomon/factories/credit_card_factory.py rename to app/tests/solomon/factories/credit_card_factory.py index a66840a..b4fc006 100644 --- a/api/tests/solomon/factories/credit_card_factory.py +++ b/app/tests/solomon/factories/credit_card_factory.py @@ -1,8 +1,8 @@ from factory import Faker, SubFactory -from api.solomon.transactions.domain.models import CreditCard -from api.tests.solomon.factories.base_factory import BaseFactory -from api.tests.solomon.factories.user_factory import UserFactory +from app.solomon.transactions.domain.models import CreditCard +from app.tests.solomon.factories.base_factory import BaseFactory +from app.tests.solomon.factories.user_factory import UserFactory class CreditCardFactory(BaseFactory): diff --git a/app/tests/solomon/factories/installment_factory.py b/app/tests/solomon/factories/installment_factory.py new file mode 100644 index 0000000..a30e4a8 --- /dev/null +++ b/app/tests/solomon/factories/installment_factory.py @@ -0,0 +1,21 @@ +import factory + +from app.solomon.transactions.presentation.models import InstallmentCreate +from app.tests.solomon.factories.base_factory import BaseFactory + + +def incrementing_sequence(start=1): + num = start + while True: + yield num + num += 1 + + +incrementing_numbers = incrementing_sequence() + + +class InstallmentCreateFactory(BaseFactory): + class Meta: + model = InstallmentCreate + + installment_number = factory.LazyAttribute(lambda x: next(incrementing_numbers)) diff --git a/app/tests/solomon/factories/transaction_factory.py b/app/tests/solomon/factories/transaction_factory.py new file mode 100644 index 0000000..593b799 --- /dev/null +++ b/app/tests/solomon/factories/transaction_factory.py @@ -0,0 +1,21 @@ +from factory import Faker +from factory.fuzzy import FuzzyChoice, FuzzyDecimal + +from app.solomon.transactions.domain.options import Kinds +from app.solomon.transactions.presentation.models import TransactionCreate +from app.tests.solomon.factories.base_factory import BaseFactory + + +class TransactionCreateFactory(BaseFactory): + class Meta: + model = TransactionCreate + + description = FuzzyChoice(["iFood", "Uber", "Formosa", "Spotify"]) + amount = FuzzyDecimal(0.01, 1000.00, precision=2) + date = Faker("date") + is_fixed = False + is_revenue = False + recurring_day = None + kind = FuzzyChoice(Kinds) + category_id = Faker("uuid4") + user_id = Faker("uuid4") diff --git a/api/tests/solomon/factories/user_factory.py b/app/tests/solomon/factories/user_factory.py similarity index 65% rename from api/tests/solomon/factories/user_factory.py rename to app/tests/solomon/factories/user_factory.py index 27c877b..1adb792 100644 --- a/api/tests/solomon/factories/user_factory.py +++ b/app/tests/solomon/factories/user_factory.py @@ -1,7 +1,7 @@ import factory -from api.solomon.users.domain.models import User -from api.tests.solomon.factories.base_factory import BaseFactory +from app.solomon.users.domain.models import User +from app.tests.solomon.factories.base_factory import BaseFactory class UserFactory(BaseFactory): diff --git a/api/tests/solomon/routes/test_routes.py b/app/tests/solomon/routes/test_routes.py similarity index 100% rename from api/tests/solomon/routes/test_routes.py rename to app/tests/solomon/routes/test_routes.py diff --git a/app/tests/solomon/transactions/application/test_transactions_handlers.py b/app/tests/solomon/transactions/application/test_transactions_handlers.py new file mode 100644 index 0000000..58988d7 --- /dev/null +++ b/app/tests/solomon/transactions/application/test_transactions_handlers.py @@ -0,0 +1,134 @@ +import datetime +from uuid import uuid4 + +from app.solomon.transactions.domain.models import Installment, Transaction +from app.solomon.transactions.domain.options import Kinds +from app.tests.solomon.factories.transaction_factory import TransactionCreateFactory + + +class TestCreditCardTransactionHandlers: + def test_process_transaction_success(self, transaction_handler, mock_repository): + # Arrange + mock_transaction_create = TransactionCreateFactory.build( + kind=Kinds.CREDIT.value, + is_fixed=False, + recurring_day=None, + credit_card_id=str(uuid4()), + installments_number=3, + amount=300, + date="2023-12-20", + ) + + mock_repository.create_with_installments.return_value = Transaction( + id=uuid4(), + description=mock_transaction_create.description, + amount=mock_transaction_create.amount, + date=mock_transaction_create.date, + recurring_day=mock_transaction_create.recurring_day, + is_fixed=mock_transaction_create.is_fixed, + is_revenue=mock_transaction_create.is_revenue, + kind=mock_transaction_create.kind, + user_id=mock_transaction_create.user_id, + category_id=mock_transaction_create.category_id, + credit_card_id=mock_transaction_create.credit_card_id, + installments=[ + Installment(id=uuid4(), amount=100, date=datetime.date(2023, 12, 20)), + Installment(id=uuid4(), amount=100, date=datetime.date(2024, 1, 20)), + Installment(id=uuid4(), amount=100, date=datetime.date(2024, 2, 20)), + ], + ) + + # Act + result = transaction_handler.process_transaction(mock_transaction_create) + + # Assert + assert result.id is not None + assert result.description == mock_transaction_create.description + assert result.amount == mock_transaction_create.amount + assert result.date == mock_transaction_create.date + assert result.recurring_day == mock_transaction_create.recurring_day + assert result.is_fixed == mock_transaction_create.is_fixed + assert result.is_revenue == mock_transaction_create.is_revenue + assert result.kind == mock_transaction_create.kind + assert result.user_id == mock_transaction_create.user_id + assert result.category_id == mock_transaction_create.category_id + assert result.credit_card_id == mock_transaction_create.credit_card_id + assert len(result.installments) == 3 + + def test_process_transaction_failure(self, transaction_handler, mock_repository): + # Arrange + mock_transaction_create = TransactionCreateFactory.build( + kind=Kinds.CREDIT.value, + is_fixed=False, + recurring_day=None, + credit_card_id=str(uuid4()), + installments_number=3, + amount=300, + date="2023-12-20", + ) + + mock_repository.create_with_installments.side_effect = Exception( + "Database not available" + ) + + # Act & Assert + try: + transaction_handler.process_transaction(mock_transaction_create) + assert False, "Exception not raised" + except Exception as e: + assert str(e) == "Database not available" + mock_repository.rollback.assert_called_once() + + +class TestInstallmentHandlers: + def test_generate_installments(self, installment_handler): + # Arrange + transaction = TransactionCreateFactory.build( + kind=Kinds.CREDIT.value, + is_fixed=False, + recurring_day=None, + credit_card_id=str(uuid4()), + installments_number=3, + amount=300, + date="2023-12-20", + ) + + # Act + installments = installment_handler.generate_installments(transaction) + + # Assert + assert len(installments) == 3 + assert installments[0].amount == 100 + assert installments[1].amount == 100 + assert installments[2].amount == 100 + + assert installments[0].date == datetime.date(2023, 12, 20) + assert installments[1].date == datetime.date(2024, 1, 20) + assert installments[2].date == datetime.date(2024, 2, 20) + + assert installments[0].installment_number == 1 + assert installments[1].installment_number == 2 + assert installments[2].installment_number == 3 + + def test_generate_installments_for_transaction_without_installments_number( + self, installment_handler + ): + # Arrange + transaction = TransactionCreateFactory.build( + kind=Kinds.CREDIT.value, + is_fixed=False, + recurring_day=None, + credit_card_id=str(uuid4()), + installments_number=None, + amount=300.15, + date="2024-02-13", + ) + + # Act + installments = installment_handler.generate_installments(transaction) + + # Assert + assert len(installments) == 1 + assert installments[0].amount == 300.15 + assert installments[0].date == datetime.date(2024, 2, 13) + assert installments[0].installment_number == 1 diff --git a/app/tests/solomon/transactions/application/test_transactions_services.py b/app/tests/solomon/transactions/application/test_transactions_services.py new file mode 100644 index 0000000..2ac58a3 --- /dev/null +++ b/app/tests/solomon/transactions/application/test_transactions_services.py @@ -0,0 +1,296 @@ +import datetime +from uuid import uuid4 + +import pytest + +from app.solomon.transactions.application.services import CreditCardService +from app.solomon.transactions.domain.exceptions import CreditCardNotFound +from app.solomon.transactions.domain.models import Installment, Transaction +from app.solomon.transactions.domain.options import Kinds +from app.tests.solomon.factories.credit_card_factory import CreditCardFactory +from app.tests.solomon.factories.installment_factory import InstallmentCreateFactory +from app.tests.solomon.factories.transaction_factory import TransactionCreateFactory + + +class TestCreditCardService: + def test_get_credit_card(self, credit_card_service, mock_repository): + mock_user_id = "123" + credit_card = CreditCardFactory.build() + mock_repository.get_by_id.return_value = credit_card + + result = credit_card_service.get_credit_card("credit_card_id", mock_user_id) + + mock_repository.get_by_id.assert_called_once_with( + id="credit_card_id", user_id=mock_user_id + ) + assert result == credit_card + + def test_get_invalid_credit_card(self, credit_card_service, mock_repository): + mock_user_id = "123" + mock_repository.get_by_id.return_value = None + + with pytest.raises(CreditCardNotFound): + credit_card_service.get_credit_card("invalid_id", mock_user_id) + + mock_repository.get_by_id.assert_called_once_with( + id="invalid_id", user_id=mock_user_id + ) + + def test_get_credit_cards(self, credit_card_service, mock_repository): + mock_user_id = "123" + mock_credit_cards = [CreditCardFactory.build(), CreditCardFactory.build()] + mock_repository.get_all.return_value = mock_credit_cards + + result = credit_card_service.get_credit_cards(mock_user_id) + + assert result == mock_credit_cards + assert isinstance(result, list) + assert len(result) == 2 + mock_repository.get_all.assert_called_once_with(user_id=mock_user_id) + + def test_create_credit_card(self, credit_card_service, mock_repository): + mock_credit_card = CreditCardFactory.build() + mock_repository.create.return_value = mock_credit_card + + result = credit_card_service.create_credit_card( + user_id=mock_credit_card.user_id, + name=mock_credit_card.name, + limit=mock_credit_card.limit, + invoice_start_day=mock_credit_card.invoice_start_day, + ) + + assert result == mock_credit_card + mock_repository.create.assert_called_once_with( + user_id=mock_credit_card.user_id, + name=mock_credit_card.name, + limit=mock_credit_card.limit, + invoice_start_day=mock_credit_card.invoice_start_day, + ) + + def test_create_invalid_credit_card(self, credit_card_service, mock_repository): + mock_credit_card = CreditCardFactory.build() + mock_repository.create.return_value = None + + result = credit_card_service.create_credit_card( + user_id=mock_credit_card.user_id, + name=mock_credit_card.name, + limit=mock_credit_card.limit, + invoice_start_day=mock_credit_card.invoice_start_day, + ) + + assert result is None + mock_repository.create.assert_called_once_with( + user_id=mock_credit_card.user_id, + name=mock_credit_card.name, + limit=mock_credit_card.limit, + invoice_start_day=mock_credit_card.invoice_start_day, + ) + + def test_update_credit_card(self, credit_card_service, mock_repository): + # Arrange + mock_credit_card = CreditCardFactory.build() + new_name = "New name" + mock_credit_card.name = new_name + + mock_repository.get_by_id.return_value = mock_credit_card + mock_repository.update.return_value = mock_credit_card + + credit_card_service = CreditCardService(mock_repository) + + # Act + updated_credit_card = credit_card_service.update_credit_card( + mock_credit_card, mock_credit_card.user_id, name=new_name + ) + + # Assert + assert updated_credit_card.name == new_name + mock_repository.update.assert_called_once_with(mock_credit_card, name=new_name) + + def test_update_credit_card_not_found(self, credit_card_service, mock_repository): + # Arrange + mock_credit_card = CreditCardFactory.build() + mock_credit_card.user_id = "test_user_id" + mock_repository.get_by_id.return_value = None + + # Act and Assert + with pytest.raises(CreditCardNotFound): + credit_card_service.update_credit_card( + mock_credit_card, mock_credit_card.user_id, name="New Name" + ) + + def test_delete_credit_card(self, credit_card_service, mock_repository): + # Arrange + mock_credit_card = CreditCardFactory.build() + mock_credit_card.user_id = "test_user_id" + mock_repository.get_by_id.return_value = mock_credit_card + + # Act + deleted_credit_card = credit_card_service.delete_credit_card( + mock_credit_card.id, mock_credit_card.user_id + ) + + # Assert + assert deleted_credit_card == mock_credit_card + mock_repository.delete.assert_called_once_with(credit_card=mock_credit_card) + + def test_delete_credit_card_not_found(self, credit_card_service, mock_repository): + # Arrange + mock_credit_card = CreditCardFactory.build() + mock_credit_card.user_id = "test_user_id" + mock_repository.get_by_id.return_value = None + + # Act and Assert + with pytest.raises(CreditCardNotFound): + credit_card_service.delete_credit_card( + mock_credit_card.id, mock_credit_card.user_id + ) + + +class TestTransactionService: + @pytest.mark.parametrize( + "kind", + [Kinds.PIX.value, Kinds.DEBIT.value, Kinds.CASH.value, Kinds.TRANSFER.value], + ) + def test_create_recurrent_transaction( + self, kind, transaction_service, mock_repository + ): + # Arrange + mock_transaction_create = TransactionCreateFactory.build( + kind=kind, is_fixed=True, date=None, recurring_day=4 + ) + mock_repository.create.return_value = Transaction( + id=str(uuid4()), + description=mock_transaction_create.description, + amount=mock_transaction_create.amount, + date=mock_transaction_create.date, + is_fixed=mock_transaction_create.is_fixed, + is_revenue=mock_transaction_create.is_revenue, + kind=mock_transaction_create.kind, + category_id=mock_transaction_create.category_id, + user_id=mock_transaction_create.user_id, + recurring_day=mock_transaction_create.recurring_day, + ) + + # Act + created_transaction = transaction_service.create_transaction( + mock_transaction_create + ) + + # Assert + assert created_transaction.is_fixed is True + assert created_transaction.recurring_day == 4 + assert created_transaction.date is None + assert created_transaction.kind == kind + assert created_transaction.user_id == mock_transaction_create.user_id + assert created_transaction.installments == [] + + def test_create_credit_card_recurrent_transaction( + self, transaction_service, mock_repository + ): + # Arrange + mock_transaction_create = TransactionCreateFactory.build( + kind=Kinds.CREDIT.value, + is_fixed=True, + date=None, + recurring_day=4, + credit_card_id=str(uuid4()), + ) + mock_repository.create.return_value = Transaction( + id=str(uuid4()), + description=mock_transaction_create.description, + amount=mock_transaction_create.amount, + date=mock_transaction_create.date, + is_fixed=mock_transaction_create.is_fixed, + is_revenue=mock_transaction_create.is_revenue, + kind=mock_transaction_create.kind, + category_id=mock_transaction_create.category_id, + user_id=mock_transaction_create.user_id, + recurring_day=mock_transaction_create.recurring_day, + ) + + # Act + created_transaction = transaction_service.create_transaction( + mock_transaction_create + ) + + # Assert + assert created_transaction.is_fixed is True + assert created_transaction.recurring_day == 4 + assert created_transaction.date is None + assert created_transaction.kind == Kinds.CREDIT.value + assert created_transaction.user_id == mock_transaction_create.user_id + assert created_transaction.installments == [] + + def test_create_credit_card_variable_transaction( + self, transaction_service, mock_repository + ): + # Arrange + mock_transaction_create = TransactionCreateFactory.build( + kind=Kinds.CREDIT.value, + is_fixed=False, + recurring_day=None, + credit_card_id=str(uuid4()), + installments_number=3, + ) + + mock_installments_create = [ + InstallmentCreateFactory.build(date="2023-12-24", amount=100.25), + InstallmentCreateFactory.build(date="2024-01-24", amount=100.25), + InstallmentCreateFactory.build(date="2024-02-24", amount=100.25), + ] + + mock_repository.create_with_installments.return_value = Transaction( + id=str(uuid4()), + description=mock_transaction_create.description, + amount=mock_transaction_create.amount, + date=mock_transaction_create.date, + is_fixed=mock_transaction_create.is_fixed, + is_revenue=mock_transaction_create.is_revenue, + kind=mock_transaction_create.kind, + category_id=mock_transaction_create.category_id, + user_id=mock_transaction_create.user_id, + recurring_day=mock_transaction_create.recurring_day, + installments=[ + Installment(**installment.model_dump(), id=str(uuid4())) + for installment in mock_installments_create + ], + ) + + # Act + created_transaction = transaction_service.create_transaction( + mock_transaction_create + ) + installments = created_transaction.installments + + # Assert + assert created_transaction.is_fixed is False + assert created_transaction.recurring_day is None + assert created_transaction.date is mock_transaction_create.date + assert created_transaction.kind == Kinds.CREDIT.value + assert created_transaction.user_id == mock_transaction_create.user_id + + # Assert installments + assert len(installments) == 3 + assert installments[0].date == datetime.date(2023, 12, 24) + assert installments[0].amount == 100.25 + assert installments[1].date == datetime.date(2024, 1, 24) + assert installments[1].amount == 100.25 + assert installments[2].date == datetime.date(2024, 2, 24) + assert installments[2].amount == 100.25 + + @pytest.mark.parametrize("kind", [kind.value for kind in Kinds]) + def test_create_invalid_recurrent_transaction(self, kind): + with pytest.raises(ValueError): + TransactionCreateFactory.build( + kind=kind, is_fixed=True, date="2024-01-15", recurring_day=None + ) + + def test_create_credit_card_invalid_variable_transaction(self): + with pytest.raises(ValueError): + TransactionCreateFactory.build( + kind=Kinds.CREDIT.value, + is_fixed=False, + recurring_day=None, + installments_number=None, + date=None, + ) diff --git a/app/tests/solomon/transactions/conftest.py b/app/tests/solomon/transactions/conftest.py new file mode 100644 index 0000000..a927f6f --- /dev/null +++ b/app/tests/solomon/transactions/conftest.py @@ -0,0 +1,37 @@ +from unittest.mock import Mock + +import pytest + +from app.solomon.transactions.application.handlers import ( + CreditCardTransactionHandler, + InstallmentHandler, +) +from app.solomon.transactions.application.services import ( + CreditCardService, + TransactionService, +) + + +@pytest.fixture +def mock_repository(): + return Mock() + + +@pytest.fixture +def credit_card_service(mock_repository): + return CreditCardService(credit_card_repository=mock_repository) + + +@pytest.fixture +def transaction_service(mock_repository): + return TransactionService(transaction_repository=mock_repository) + + +@pytest.fixture +def transaction_handler(mock_repository): + return CreditCardTransactionHandler(transaction_repository=mock_repository) + + +@pytest.fixture +def installment_handler(): + return InstallmentHandler() diff --git a/api/tests/solomon/transactions/presentation/test_categories_resources.py b/app/tests/solomon/transactions/presentation/test_categories_resources.py similarity index 100% rename from api/tests/solomon/transactions/presentation/test_categories_resources.py rename to app/tests/solomon/transactions/presentation/test_categories_resources.py diff --git a/api/tests/solomon/transactions/presentation/test_credit_cards_resources.py b/app/tests/solomon/transactions/presentation/test_credit_cards_resources.py similarity index 100% rename from api/tests/solomon/transactions/presentation/test_credit_cards_resources.py rename to app/tests/solomon/transactions/presentation/test_credit_cards_resources.py diff --git a/app/tests/solomon/transactions/presentation/test_transactions_resources.py b/app/tests/solomon/transactions/presentation/test_transactions_resources.py new file mode 100644 index 0000000..43b3fb1 --- /dev/null +++ b/app/tests/solomon/transactions/presentation/test_transactions_resources.py @@ -0,0 +1,103 @@ +import datetime +from unittest.mock import patch + +from fastapi.encoders import jsonable_encoder +from fastapi_sqlalchemy import db + +from app.solomon.transactions.domain.options import Kinds + + +class TestTransactionsResources: + def test_create_recurrent_transaction( + self, + auth_client, + current_user, + transaction_create_factory, + category_factory, + ): + with db(): + category = category_factory.create() + body = transaction_create_factory.build( + kind=Kinds.PIX.value, + is_fixed=True, + date=None, + recurring_day=4, + user_id=current_user.id, + category_id=category.id, + ).dict() + + response = auth_client.post("/transactions/", json=body) + result = response.json() + + assert response.status_code == 201 + assert result["description"] == body["description"] + assert result["kind"] == body["kind"] + assert result["is_fixed"] == body["is_fixed"] + assert result["installments"] == [] + + def test_create_credit_card_variable_transaction( + self, + auth_client, + current_user, + transaction_create_factory, + category_factory, + credit_card_factory, + ): + with db(): + category = category_factory.create() + credit_card = credit_card_factory.create(user=current_user) + body = transaction_create_factory.build( + kind=Kinds.CREDIT.value, + is_fixed=False, + recurring_day=None, + user_id=current_user.id, + credit_card_id=credit_card.id, + category_id=category.id, + amount=300.00, + installments_number=3, + date=datetime.date(2023, 5, 1), + ).dict() + + response = auth_client.post("/transactions/", json=jsonable_encoder(body)) + result = response.json() + + assert response.status_code == 201 + assert result["description"] == body["description"] + assert result["kind"] == body["kind"] + assert result["is_fixed"] == body["is_fixed"] + assert len(result["installments"]) == 3 + assert result["installments"][0]["amount"] == 100.00 + assert result["installments"][0]["date"] == "2023-05-01" + + def test_create_invalid_credit_card_variable_transaction( + self, + auth_client, + current_user, + transaction_create_factory, + category_factory, + credit_card_factory, + ): + with db(): + category = category_factory.create() + credit_card = credit_card_factory.create(user=current_user) + body = transaction_create_factory.build( + kind=Kinds.CREDIT.value, + is_fixed=False, + recurring_day=None, + user_id=current_user.id, + credit_card_id=credit_card.id, + category_id=category.id, + amount=300.00, + installments_number=3, + date=datetime.date(2023, 5, 1), + ).dict() + + # Substitua create_with_installments por um mock que lança uma exceção + with patch( + "app.solomon.transactions.infrastructure.repositories.TransactionRepository.create_with_installments", + side_effect=Exception("Database Not Available"), + ): + response = auth_client.post( + "/transactions/", json=jsonable_encoder(body) + ) + assert response.status_code == 500 diff --git a/api/tests/solomon/users/application/test_users_services.py b/app/tests/solomon/users/application/test_users_services.py similarity index 83% rename from api/tests/solomon/users/application/test_users_services.py rename to app/tests/solomon/users/application/test_users_services.py index a772a19..abf7fc0 100644 --- a/api/tests/solomon/users/application/test_users_services.py +++ b/app/tests/solomon/users/application/test_users_services.py @@ -2,11 +2,11 @@ import pytest -from api.solomon.auth.presentation.models import UserCreate -from api.solomon.users.application.services import UserService -from api.solomon.users.domain.exceptions import UserAlreadyExists -from api.solomon.users.domain.models import User -from api.solomon.users.infrastructure.repositories import UserRepository +from app.solomon.auth.presentation.models import UserCreate +from app.solomon.users.application.services import UserService +from app.solomon.users.domain.exceptions import UserAlreadyExists +from app.solomon.users.domain.models import User +from app.solomon.users.infrastructure.repositories import UserRepository def test_create_user(): diff --git a/docs/use_cases/transaction.md b/docs/use_cases/transaction.md new file mode 100644 index 0000000..3b5d6a9 --- /dev/null +++ b/docs/use_cases/transaction.md @@ -0,0 +1,27 @@ +# Transações + +Uma transação financeira refere-se a qualquer atividade que envolva a transferência de dinheiro ou ativos entre duas partes. Essas transações podem ocorrer em diversos contextos, como compras, vendas, investimentos, empréstimos, pagamentos, entre outros. + +Existem diferentes formas de realizar transações financeiras, incluindo o uso de dinheiro físico, cheques, cartões de crédito, transferências eletrônicas, pagamentos móveis e criptomoedas, por exemplo. Cada tipo de transação pode envolver diferentes processos, instrumentos e sistemas, dependendo da natureza da atividade e das partes envolvidas. + +## Possíveis tipos de transações no Solomon: + +#### Quanto ao tipo: +- Dinheiro +- Cartão de Crédito +- Débito +- Pix +- Transferência Bancária + +#### Quanto a classificação: +- Fixa ou Variável +- Receita ou Despesa + + +## Regras para cadastrar uma transação: +1. Se o tipo de transação for `cartão de crédito`, ela obrigatóriamente deverá ser +associada a um cartão. +2. Se o tipo de transação for `cartão de crédito` e ela for `variável`, deverá ser +informado o número de `parcelas` e a `data` da transação. +3. Se o tipo de transação for `fixa`, então ela não terá uma `data`, nem `parcelas`, +mas terá um `dia de recorrência`. diff --git a/pytest.ini b/pytest.ini index 73c5fe3..263b0a5 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ [pytest] # addopts = --cov=. --cov-config=.coveragerc --cov-report=term-missing -testpaths = api/tests +testpaths = app/tests env = ENV=test filterwarnings =