diff --git a/carta.md b/carta.md new file mode 100644 index 000000000..c11d6c360 --- /dev/null +++ b/carta.md @@ -0,0 +1,40 @@ +Olá, pessoal. + +Realizei este teste de Backend, no qual construí uma API seguindo os requisitos propostos no sistema/caso apresentado. + +Utilizei a linguagem Python em conjunto com o framework Django e Django Rest Framework (DRF), estruturando o projeto no padrão MVT. + +A escolha do Django se deu principalmente pela possibilidade de implementar um sistema com um usuário genérico desacoplado da entidade Investidor, aproveitando o User padrão fornecido pelo framework, bem como sua interface administrativa nativa. + +Além disso, o ORM e os querysets do Django auxiliam significativamente no desempenho das consultas, especialmente considerando as regras de negócio envolvidas. Em um cenário de maior complexidade, seria possível otimizar ainda mais as queries, incluindo joins entre tabelas de forma eficiente. + +Os serializers do Django foram utilizados como responsáveis pelo tratamento e validação dos dados, o que contribui diretamente para a produtividade do desenvolvedor e para a segurança da aplicação. + +Os Signals (gatilhos) do Django foram empregados como parte essencial da integração com o serviço externo de SMTP, conforme solicitado nos requisitos. + +Portanto, a escolha da stack não se deu apenas por afinidade técnica ou aprendizado contínuo, mas também pela produtividade, organização e nível de abstração que o Django oferece. + +As entidades foram divididas em: + +- Usuários + +- Investidores + +- Investimentos + +Foi implementado o **CRUD** completo para Investidores e Investimentos. + +No caso dos investimentos, priorizei a segurança e a integridade dos registros, utilizando uma flag booleana para indicar o encerramento, evitando alterações indevidas em registros históricos. + +Como melhorias futuras, considero a implementação de permissões e autenticação mais refinadas, garantindo o uso adequado de cada endpoint. Além disso, seria interessante a criação de uma entidade Wallet, responsável por consolidar os investimentos, métricas e regras de negócio relacionadas. + +Também utilizei bibliotecas auxiliares, todas devidamente documentadas no arquivo de requirements e na documentação do projeto. + +Para padronização e formatação do código, utilizei o Ruff, visando manter um padrão de leitura consistente, tento sempre seguir padrão python, mas tenho costumes de aspas e outros toques. +Para automação de tarefas, utilizei o Taskipy, facilitando a execução de comandos recorrentes. Para documentação, utilizei o MkDocs, buscando uma melhor organização e visualização dos arquivos Markdown. + +As variáveis de ambiente foram devidamente protegidas e configuradas por meio de um arquivo .env local. + +Fico à disposição para receber feedbacks. Gostaria muito de ouvir a opinião de vocês sobre pontos de melhoria e aspectos positivos do projeto. + +Agradeço pela oportunidade, Prazer! \ No newline at end of file diff --git a/code/.gitignore b/code/.gitignore new file mode 100644 index 000000000..d625ce0c4 --- /dev/null +++ b/code/.gitignore @@ -0,0 +1,115 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ +.vscode +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +.DS_Store +*.sqlite3 +media/ +*.pyc +*.db +*.pid + +# Ignore Django Migrations in Development if you are working on team + +# Only for Development only +# **/migrations/** +# !**/migrations +# !**/migrations/__init__.py \ No newline at end of file diff --git a/code/business/__init__.py b/code/business/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/business/investiments.py b/code/business/investiments.py new file mode 100644 index 000000000..66d6838f4 --- /dev/null +++ b/code/business/investiments.py @@ -0,0 +1,146 @@ +import datetime +from decimal import ROUND_HALF_EVEN, Decimal +from typing import Optional + +from django.utils import timezone + + +class InvestimentBusiness: + """Lógica de Negócio do Investimento + + O investimento renderá 0,52% todos os meses, + no mesmo dia em que for realizado. + Dado que o ganho é pago mensalmente, deve ser tratado como ganho composto, + o que significa que a cada novo período (mês) + o valor ganho passará a fazer parte do saldo do investimento para + o próximo pagamento. + + """ + + MONTHLY_RATE = Decimal('0.0052') + + def __init__( + self, + investiment_value: Decimal, + investiment_created_at: datetime.datetime, + withdrawn_created_at: Optional[datetime.datetime] = None, + ): + self.investiment_value = investiment_value + self.investiment_created_at = investiment_created_at + self.withdrawn_created_at = withdrawn_created_at + + def calculate_amount(self) -> Decimal: + """ + Calculo do Montante de um investimento aberto(data atual). + """ + initial_date = self.investiment_created_at.date() + today = timezone.localdate() + + months = self._full_months_between(initial_date, today) + amount = ( + self.investiment_value + * ( # Juros composto + Decimal('1') + self.MONTHLY_RATE + ) + ** months + ) + + return amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN) + + def calculate_gains(self) -> Decimal: + """ + Cálculo dos ganhos (montante - investido) em um investimento + aberto(Data atual). + """ + amount = self.calculate_amount() + gains = amount - self.investiment_value + + return gains.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN) + + def calculate_amount_withdrawn(self) -> Decimal: + """ + Saldo Montante em um investimento fechado. + """ + initial_date = self.investiment_created_at.date() + withdrawn_date = self.withdrawn_created_at.date() + + months = self._full_months_between(initial_date, withdrawn_date) + + amount = ( + self.investiment_value + * (Decimal('1') + self.MONTHLY_RATE) ** months + ) + + return amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN) + + def calculate_gains_withdrawn(self) -> Decimal: + """Calculo de Ganho em um investimento Fechado.""" + amount = self.calculate_amount_withdrawn() + gains = amount - self.investiment_value + + return gains.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN) + + def calculate_net_amount_withdrawn(self): + """ + Calculo do montante para saque de um investimento fechado + (A tributar os ganhos). + + Se tiver menos de um ano, a percentagem será de **22,5%** + (imposto = 45,00). + + Se tiver entre um e dois anos, a percentagem será de **18,5%** + (imposto = 37,00). + + Se tiver mais de dois anos, a percentagem será de **15%** + (imposto = 30,00). + """ + amount = self.calculate_amount_withdrawn() + gains = amount - self.investiment_value + initial_date = self.investiment_created_at.date() + withdrawn_date = self.withdrawn_created_at.date() + + TAX_UNDER_12 = Decimal('0.225') + TAX_12_TO_24 = Decimal('0.185') + TAX_OVER_24 = Decimal('0.15') + + months = self._full_months_between(initial_date, withdrawn_date) + + if months < 12: + gains_tax = gains * TAX_UNDER_12 + elif months > 24: + gains_tax = gains * TAX_OVER_24 + else: + gains_tax = gains * TAX_12_TO_24 + + return (amount - gains_tax).quantize( + Decimal('0.01'), rounding=ROUND_HALF_EVEN + ) # saldo montante(amount) limpo para retorno. + + def calculate_net_gains_withdrawn(self) -> Decimal: + """ + Calculo dos Ganhos de um investimento fechado e tributado. + """ + amount = self.calculate_net_amount_withdrawn() + initial_investiment = self.investiment_value + + return (amount - initial_investiment).quantize( + Decimal('0.01'), rounding=ROUND_HALF_EVEN + ) + + def _full_months_between(self, start, end): + """ + Calcula a quantidade de meses **inteiros** entre duas datas. + + Um mês só é contabilizado se o período completar um ciclo mensal cheio, + isto é, o dia do mês em `end` deve ser maior ou = dia em `start`. + + Regra de negócio: + - Meses parciais não são considerados. + - A contagem só avança quando o mês seguinte é completado. + + exemplo: 12/09 -> 11/10 = 0 meses; + """ + months = (end.year - start.year) * 12 + (end.month - start.month) + if end.day < start.day: + months -= 1 + return max(months, 0) diff --git a/code/business/tests/__init__.py b/code/business/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/business/tests/test_investiments.py b/code/business/tests/test_investiments.py new file mode 100644 index 000000000..c6e85c7cb --- /dev/null +++ b/code/business/tests/test_investiments.py @@ -0,0 +1,69 @@ +from decimal import Decimal +from django.utils import timezone +from datetime import datetime +from django.test import TestCase +from business.investiments import InvestimentBusiness + + +class InvestimentBusinessOpenTest(TestCase): + def setUp(self): + self.investiment = make_investiment( + value=Decimal('1000'), + created_at=timezone.make_aware(datetime(2025, 11, 13, 0, 0)), + withdrawn_at=timezone.make_aware(datetime(2025, 12, 12, 0, 0)), + ) + + def test_calculate_amount_is_equal(self): + self.assertEqual( + self.investiment.calculate_amount(), Decimal('1005.20') + ) + + def test_calculate_gains_is_equal(self): + self.assertEqual(self.investiment.calculate_gains(), Decimal('5.20')) + + +class InvestimentBusinessWithdrawnTest(TestCase): + def setUp(self): + self.investiment = make_investiment( + value=Decimal('1000'), + created_at=timezone.make_aware(datetime(2025, 11, 13, 0, 0)), + withdrawn_at=timezone.make_aware(datetime(2025, 12, 12, 0, 0)), + ) + + def test_calculate_amount_withdrawn_is_equal(self): + self.assertEqual( + self.investiment.calculate_amount_withdrawn(), Decimal('1000.00') + ) + + def test_calculate_gains_withdrawn_is_equal(self): + self.assertEqual( + self.investiment.calculate_gains_withdrawn(), Decimal('0') + ) + + +class InvestimentBusinessNetWithdrawnTest(TestCase): + def setUp(self): + self.investiment = make_investiment( + value=Decimal('1000'), + created_at=timezone.make_aware(datetime(2025, 11, 13, 0, 0)), + withdrawn_at=timezone.make_aware(datetime(2025, 12, 12, 0, 0)), + ) + + def test_calculate_net_amount_withdrawn_is_equal(self): + self.assertEqual( + self.investiment.calculate_net_amount_withdrawn(), Decimal('1000') + ) + + def test_calculate_net_gains_withdrawn_is_equal(self): + self.assertEqual( + self.investiment.calculate_net_gains_withdrawn(), Decimal('0') + ) + + +def make_investiment(*, value='1000.00', created_at, withdrawn_at=None): + """Top-Level Construtora da classe de testes""" + return InvestimentBusiness( + investiment_value=Decimal(value), + investiment_created_at=created_at, + withdrawn_created_at=withdrawn_at, + ) diff --git a/code/core/__init__.py b/code/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/core/asgi.py b/code/core/asgi.py new file mode 100644 index 000000000..cf099bf89 --- /dev/null +++ b/code/core/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for core 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/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_asgi_application() diff --git a/code/core/settings.py b/code/core/settings.py new file mode 100644 index 000000000..4d5c9d8ae --- /dev/null +++ b/code/core/settings.py @@ -0,0 +1,150 @@ +import os + +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/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DEBUG') + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + # apps + 'investiments', + 'investors', +] + +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 = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/6.0/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/6.0/topics/i18n/ + +LANGUAGE_CODE = 'pt-br' + +TIME_ZONE = 'America/Sao_Paulo' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = 'static/' + + +if DEBUG: + EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +else: + EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = os.getenv('EMAIL_USER') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_PASSWORD') + + +# Pag10 +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 10, +} +# limit=100&offset=400 + + +# logging +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} diff --git a/code/core/urls.py b/code/core/urls.py new file mode 100644 index 000000000..194c1c17f --- /dev/null +++ b/code/core/urls.py @@ -0,0 +1,37 @@ +""" +URL configuration for core project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path, include +from users.views import UserListCreateView + +urlpatterns = [ + path('admin/', admin.site.urls), + path( + 'api/v1/users/register/', + UserListCreateView.as_view(), + name='user-create-list', + ), + path( + '', + include('investors.urls'), + ), + path( + '', + include('investiments.urls'), + ), +] diff --git a/code/core/wsgi.py b/code/core/wsgi.py new file mode 100644 index 000000000..6d3653019 --- /dev/null +++ b/code/core/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for core 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/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_wsgi_application() diff --git a/code/docs/images/layout_modelagem_investiments_api.png b/code/docs/images/layout_modelagem_investiments_api.png new file mode 100644 index 000000000..b26e08026 Binary files /dev/null and b/code/docs/images/layout_modelagem_investiments_api.png differ diff --git a/code/docs/index.md b/code/docs/index.md new file mode 100644 index 000000000..2517308f4 --- /dev/null +++ b/code/docs/index.md @@ -0,0 +1,388 @@ +# API de Investimentos + +Esta documentação descreve a API de **Investimentos**, responsável por criar investimentos, calcular ganhos e realizar o resgate (withdrawal) de forma segura e consistente. + + +A API foi desenhada seguindo boas práticas de **Django Rest Framework**, separando claramente: + +- **Model** → persistência e estado +- **Serializer** → validação e contrato da API +- **Business (Service Layer)** → regras de negócio e cálculos financeiros +- **Views** → orquestração HTTP +- **Services** → Conexões externas +- **Signals** → Gatilhos +- **Business Tests** → Teste da Camada de negócio. + +--- + +## 📌 Princípios adotados + +- Cálculos financeiros feitos **exclusivamente com `Decimal`** +- Arredondamento explícito (`ROUND_HALF_EVEN`) +- Uso de **timezone-aware datetimes** (`django.utils.timezone`) +- Regra de negócio fora do serializer (Service Layer) +- Serializers responsáveis apenas por: + - validação + - transformação de dados +- Endpoints idempotentes e previsíveis + +--- + +## 🧩 Modelo: Investiment + +Representa um investimento financeiro único. + +### Campos principais + +| Campo | Tipo | Descrição | +|------|------|-----------| +| `id` | UUID | Identificador único do investimento | +| `value` | Decimal | Valor inicial investido (mínimo R$ 0,01) | +| `investor` | FK (`Investor`) | Investidor associado ao investimento | +| `was_withdrawn` | Boolean | Indica se o investimento já foi resgatado | +| `withdrawn_created_at` | DateTime (nullable) | Data em que o resgate do investimento foi registrado | +| `created_at` | DateTime | Data de criação do investimento (pode ser passada) | +| `updated_at` | DateTime | Data da última atualização do registro | + +--- + +## 💼 Modelo: Investor + +Representa um investidor registrado no sistema. Cada investidor está associado a um usuário do Django e possui CPF para identificação. + +### Campos principais + +| Campo | Tipo | Descrição | +|-------|------|-----------| +| `id` | ID | Identificador único do investidor | +| `user` | OneToOneField (User) | Usuário do Django associado ao investidor. Garante relação 1:1 e protege contra deleção acidental. | +| `name` | CharField | Nome completo do investidor | +| `cpf` | CharField | CPF do investidor, utilizado para identificação fiscal no Brasil | + +--- + +## 🧠 Camada de Negócio (Service Layer) + +Classe responsável **exclusivamente** pelos cálculos financeiros: + +- Juros compostos mensais +- Saldo esperado +- Ganhos líquidos (com tributação) +- Cálculo baseado em delta de meses + +### Taxa utilizada + +```python +MONTHLY_RATE = Decimal('0.0052') # 0.52% a.m. +``` + +### Métodos disponíveis + +| Método | Descrição | +|------|----------| +| `calculate_amount()` | Montante atual (valor + juros) | +| `calculate_gains()` | Ganhos brutos atuais | +| `calculate_amount_withdrawn()` | Montante bruto no momento do saque | +| `calculate_gains_withdrawn()` | Ganhos brutos no saque | +| `calculate_net_amount_withdrawn()` | Montante líquido após imposto | +| `calculate_net_gains_withdrawn()` | Ganhos líquidos após imposto | + +📌 **Observação:** +Todos os retornos são `Decimal` já arredondados para 2 casas decimais. + +--- +## 🔁 Serializers + +### 📄 InvestimentListSerializer + +Usado no **GET /investiments/** + +Responsável por expor uma visão consolidada do investimento. + +#### Campos retornados + +| Campo | Descrição | +|------|----------| +| `value` | Valor investido | +| `gains` | Ganhos (atuais ou no saque) | +| `balance_amount` | Saldo total esperado | +| `created_at` | Data de criação | +| `was_withdrawn` | Status do investimento | +| `withdrawn_created_at` | Data do saque (se existir) | + +📌 **Regra importante:** +- Se o investimento **não foi sacado**, os valores refletem o estado atual +- Se **já foi sacado**, os valores refletem o estado no momento do saque + +--- + +### ➕ InvestimentCreateSerializer + +Usado no **POST /investiments/** + +#### Validações + +- `created_at` não pode ser uma data futura + +```python +if value > timezone.now(): + raise ValidationError('Data futura não é permitida') +``` + +--- + +### 💸 InvestimentWithdrawnSerializer + +Usado no **PATCH /investiments/{id}/withdrawn/** + +Responsável por realizar o resgate do investimento. + +#### Regras de validação + +- O investimento **não pode** já ter sido resgatado +- A data de saque: + - não pode ser futura + - não pode ser anterior à criação +- Caso nenhuma data seja informada, o sistema utiliza `timezone.now()` + +#### Comportamento do update + +- Define `withdrawn_created_at` +- Marca `was_withdrawn = True` +- Retorna o **valor líquido do saque** + +--- + +## 🌐 Endpoints + +### 🔍 Listar investimentos + +```http +GET /api/v1/investiments/ +``` + +#### Exemplo de resposta + +```json +{ + "id": "uuid", + "value": "1213.00", + "gains": 6.31, + "balance_amount": 1219.31, + "was_withdrawn": false +} +``` + +--- + +### ➕ Criar investimento + +```http +POST /api/v1/investiments/ +``` + +```json +{ + "investor": "uuid", + "value": "1200.00", + "created_at": "2025-11-12T01:00:00-03:00" +} +``` + +--- + +### 💸 Resgatar investimento + +```http +PATCH /api/v1/investiments/{id}/withdrawn/ +``` + +```json +{ + "withdrawn_created_at": "2025-12-16T14:54:44-03:00" +} +``` + +📌 O campo é opcional. Se omitido, o backend usa o horário atual. + +#### Resposta + +```json +{ + "id": "uuid", + "was_withdrawn": true, + "withdrawal_amount": 1217.89, + "withdrawn_created_at": "2025-12-16T14:54:44-03:00" +} +``` + +--- + +## 👤 Usuários & Investidores + +### 🧑‍💻 Usuário + +```http +POST /api/v1/users/register/ +``` +Corpo da requisição +```json +{ + "username": "johndoe", + "email": "john@example.com", + "password": "strong_password_123" +} +``` +Resposta de exemplo: +```json +{ + "id": 1, + "username": "johndoe", + "email": "john@example.com" +} + +``` + +```http +GET /api/v1/users/register/ +``` + +Listar usuários Django + +```json +[ + { + "id": 1, + "username": "johndoe", + "email": "john@example.com" + } +] +``` + +### 🧑💸 Investidor + + +```http +GET /api/v1/investors/ +``` + +```json +[ + { + "id": 1, + "user": 1, + "name": "João Silva" + } +] + +``` + +➕ Criar investidor + +```http +POST /api/v1/investors/ +``` +Corpo da Request +```json +{ + "user": 1, + "name": "João Silva" +} +``` + +Exemplo de Resposta +```json +{ + "id": 1, + "user": 1, + "name": "João Silva" +} +``` +Detalhar um Investidor: +```http +GET /api/v1/investors/{id}/ +``` +```json +{ + "id": 1, + "user": 1, + "name": "João Silva" +} +``` +Deletar um Investidor: +```http +Delete /api/v1/investors/{id}/ +``` +**!Banco de Dados integro por padrão, verficiar On_delete no model** +Obs: Coloquei nessa Api, mas normalmente, se não há necessidade, trabalho sem Deletes, +em tabelas de registro isso é obrigatório, tal motivo usei uma flag em investimentos. 👍 + +--- + +## ⏱️ Datas e Fuso Horário + +- Toda a aplicação utiliza **timezone-aware datetimes** +- O Django gerencia conversões automaticamente +- Cálculos mensais usam apenas a **parte da data** (`date()`), evitando erros de fuso + +--- + +--- +*Modelagem De Dados* Desenvolvida para funcionamento da API +![Modelagem Bd](images/layout_modelagem_investiments_api.png) +*wallet foi uma ideia futura para armazenamento desses saldos investidos. +--- + +## 💡 Considerações finais + +- O backend é a **fonte da verdade** para cálculos financeiros +- O frontend consome valores já tratados e arredondados +- Conversão para `float` no JSON **não afeta os cálculos**, pois eles já foram finalizados + +Este design garante: + +✅ Precisão financeira +✅ Clareza de responsabilidades +✅ Facilidade de manutenção +✅ Segurança nas regras de negócio + +Arquitetura MVT baseada no atendimento aos requisitos pedidos e a proposta do Framework Django Rest. + +Format: +Tenho um padrão de código bem parecido com o padrão, buscando sempre melhorar, todavia para +facilitar a leitura da equipe e possível manutencao, usei o ruff para format. + +--- + +Requirements: + +```python +asgiref==3.11.0 +click==8.3.1 +colorama==0.4.6 +Django==6.0 +djangorestframework==3.16.1 +ghp-import==2.1.0 +Jinja2==3.1.6 +Markdown==3.10 +MarkupSafe==3.0.3 +mergedeep==1.3.4 +mkdocs==1.6.1 +mkdocs-get-deps==0.2.0 +mslex==1.3.0 +packaging==25.0 +pathspec==0.12.1 +platformdirs==4.5.1 +psutil==6.1.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +PyYAML==6.0.3 +pyyaml_env_tag==1.1 +ruff==0.14.10 +six==1.17.0 +sqlparse==0.5.5 +taskipy==1.14.1 +tomli==2.3.0 +tzdata==2025.3 +watchdog==6.0.0 +``` \ No newline at end of file diff --git a/code/investiments/__init__.py b/code/investiments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/investiments/admin.py b/code/investiments/admin.py new file mode 100644 index 000000000..3235479d4 --- /dev/null +++ b/code/investiments/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from .models import Investiment + + +class InvestimentAdmin(admin.ModelAdmin): + list_display = [ + 'id', + 'value', + 'investor', + 'created_at', + ] + search_fields = [ + 'investor', + 'cpf', + ] + + +admin.site.register(Investiment, InvestimentAdmin) diff --git a/code/investiments/apps.py b/code/investiments/apps.py new file mode 100644 index 000000000..0c7628e30 --- /dev/null +++ b/code/investiments/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class InvestimentsConfig(AppConfig): + name = 'investiments' + + def ready(self): + import investiments.signals diff --git a/code/investiments/migrations/0001_initial.py b/code/investiments/migrations/0001_initial.py new file mode 100644 index 000000000..2eeb4b266 --- /dev/null +++ b/code/investiments/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0 on 2025-12-22 16:52 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +import uuid +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('investors', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Investiment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('value', models.DecimalField(decimal_places=2, help_text='Valor Inicial do investimento.', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'), message='Valor Mínimo = R$ 0,01')], verbose_name='Valor')), + ('was_withdrawn', models.BooleanField(default=False, help_text='Campo bool responsável pela a atividade do investimento.', verbose_name='Foi retirado')), + ('withdrawn_created_at', models.DateTimeField(blank=True, help_text='Data em que a retirada do investimento foi registrada.', null=True, verbose_name='Data da retirada')), + ('created_at', models.DateTimeField(default=django.utils.timezone.now, help_text='Data de criação do investimento (pode ser data passada).', verbose_name='Criado em')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Atualizado em')), + ('investor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='investors.investor', verbose_name='Investidor')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/code/investiments/migrations/__init__.py b/code/investiments/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/investiments/models.py b/code/investiments/models.py new file mode 100644 index 000000000..f333637bc --- /dev/null +++ b/code/investiments/models.py @@ -0,0 +1,58 @@ +import uuid + +from decimal import Decimal +from django.db import models +from django.core.validators import MinValueValidator +from django.utils import timezone +from investors.models import Investor + + +class Investiment(models.Model): + id = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + value = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[ + MinValueValidator( # Invariante Estrutural + Decimal('0.01'), + message='Valor Mínimo = R$ 0,01', + ) + ], + verbose_name='Valor', + help_text='Valor Inicial do investimento.', + ) + investor = models.ForeignKey( + Investor, + on_delete=models.PROTECT, + verbose_name='Investidor', + ) + was_withdrawn = models.BooleanField( + default=False, + verbose_name='Foi retirado', + help_text='Campo bool responsável pela a atividade do investimento.', + ) + withdrawn_created_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Data da retirada', + help_text='Data em que a retirada do investimento foi registrada.', + ) + created_at = models.DateTimeField( + default=timezone.now, + verbose_name='Criado em', + help_text='Data de criação do investimento (pode ser data passada).', + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name='Atualizado em', + ) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return str(self.id) diff --git a/code/investiments/serializers.py b/code/investiments/serializers.py new file mode 100644 index 000000000..d6779cac0 --- /dev/null +++ b/code/investiments/serializers.py @@ -0,0 +1,133 @@ +from rest_framework import serializers +from django.utils import timezone + +from business.investiments import InvestimentBusiness +from .models import Investiment + + +class InvestimentListSerializer(serializers.ModelSerializer): + balance_amount = serializers.SerializerMethodField() + gains = serializers.SerializerMethodField() + + class Meta: + model = Investiment + fields = [ + 'id', + 'investor', + 'value', + 'gains', + 'created_at', + 'balance_amount', + 'was_withdrawn', + 'withdrawn_created_at', + 'updated_at', + ] + + def _business(self, obj): + """ + Reaproveita o código Classe das regras de negócio para atribuir + no objeto em instância -> Investimento. + """ + if not hasattr(obj, '_business_cache'): + obj._business_cache = InvestimentBusiness( + investiment_value=obj.value, + investiment_created_at=obj.created_at, + withdrawn_created_at=obj.withdrawn_created_at, + ) + return obj._business_cache + + def get_gains(self, obj): + business = self._business(obj) + + if obj.withdrawn_created_at: + return business.calculate_gains_withdrawn() + + return business.calculate_gains() + + def get_balance_amount(self, obj): + business = self._business(obj) + + if obj.withdrawn_created_at: + return business.calculate_amount_withdrawn() + + return business.calculate_amount() + + +class InvestimentCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Investiment + fields = [ + 'id', + 'created_at', + 'investor', + 'value', + ] + read_only_fields = [ + 'id', + ] + extra_kwargs = {'value': {'write_only': True}} + + def validate_created_at(self, value): + if value > timezone.now(): + raise serializers.ValidationError({'Data futura não é permitida.'}) + return value + + +class InvestimentWithdrawnSerializer(serializers.ModelSerializer): + withdrawal_amount = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = Investiment + fields = [ + 'id', + 'created_at', + 'was_withdrawn', + 'withdrawal_amount', + 'withdrawn_created_at', + ] + read_only_fields = [ + 'id', + 'created_at', + 'was_withdrawn', + 'withdrawal_amount', + ] + + def get_withdrawal_amount(self, obj): + investiment = InvestimentBusiness( + obj.value, + obj.created_at, + obj.withdrawn_created_at, + ) + return investiment.calculate_net_amount_withdrawn() + + def validate(self, attrs): + withdrawn_date = attrs.get('withdrawn_created_at') + now = timezone.now() + + if self.instance.was_withdrawn: + raise serializers.ValidationError( + 'Este investimento já foi resgatado.' + ) + + if withdrawn_date: + if withdrawn_date > now: + raise serializers.ValidationError({ + 'withdrawn_created_at': 'Data Futura não permitida.' + }) + + if withdrawn_date < self.instance.created_at: + raise serializers.ValidationError({ + 'withdrawn_created_at': 'Data não pode ser anterior à criação do investimento.' + }) + + return attrs + + def update(self, instance, validated_data): + now = timezone.now() + withdrawn_date = validated_data.get('withdrawn_created_at') + + instance.withdrawn_created_at = withdrawn_date or now + + instance.was_withdrawn = True + instance.save() + return instance diff --git a/code/investiments/signals.py b/code/investiments/signals.py new file mode 100644 index 000000000..f03a217a6 --- /dev/null +++ b/code/investiments/signals.py @@ -0,0 +1,32 @@ +import logging + +from django.dispatch import receiver +from django.db.models.signals import post_save +from django.utils import timezone +from .models import Investiment +from services.investiments import InvestimentService + +logger = logging.getLogger(__name__) +investiment_service = InvestimentService() + + +@receiver(post_save, sender=Investiment) +def send_investiment_created_event(sender, instance, created, **kwargs): + try: + if created: + data = dict( + event_type='create-investiment', + investiment_id=instance.id, + value=instance.value, + investor=instance.investor, + timestamp_created_at=timezone.localtime( + instance.created_at + ).strftime('%Y/%m/%d, %H:%M:%S'), + ) + + investiment_service.send_created_investiment_email(data=data) + logger.info(f'enviando Ok! {data.get("timestamp_created_at")}') + + except Exception as e: + logger.error(f'[ERRO SIGNAL Investiment-create] {e}') + pass diff --git a/code/investiments/urls.py b/code/investiments/urls.py new file mode 100644 index 000000000..b363e3b22 --- /dev/null +++ b/code/investiments/urls.py @@ -0,0 +1,24 @@ +from django.urls import path +from .views import ( + InvestimentListCreateApiView, + InvestimentRetrieveApiView, + InvestimentWithdrawnUpdateApiView, +) + +urlpatterns = [ + path( + 'api/v1/investiments/', + InvestimentListCreateApiView.as_view(), + name='investiment-list-create', + ), + path( + 'api/v1/investiments//', + InvestimentRetrieveApiView.as_view(), + name='investiment-detail-update-delete', + ), + path( + 'api/v1/investiments//withdrawn/', + InvestimentWithdrawnUpdateApiView.as_view(), + name='investiment-withdrawn-update', + ), +] diff --git a/code/investiments/views.py b/code/investiments/views.py new file mode 100644 index 000000000..017d5fbd6 --- /dev/null +++ b/code/investiments/views.py @@ -0,0 +1,31 @@ +from rest_framework.generics import ( + ListCreateAPIView, + RetrieveAPIView, + UpdateAPIView, +) + +from .models import Investiment +from .serializers import ( + InvestimentListSerializer, + InvestimentCreateSerializer, + InvestimentWithdrawnSerializer, +) + + +class InvestimentListCreateApiView(ListCreateAPIView): + queryset = Investiment.objects.all() + + def get_serializer_class(self): + if self.request.method == 'GET': + return InvestimentListSerializer + return InvestimentCreateSerializer + + +class InvestimentRetrieveApiView(RetrieveAPIView): + queryset = Investiment.objects.all() + serializer_class = InvestimentListSerializer + + +class InvestimentWithdrawnUpdateApiView(UpdateAPIView): + queryset = Investiment.objects.all() + serializer_class = InvestimentWithdrawnSerializer diff --git a/code/investors/__init__.py b/code/investors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/investors/admin.py b/code/investors/admin.py new file mode 100644 index 000000000..cef9c9f84 --- /dev/null +++ b/code/investors/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from .models import Investor + + +class InvestorAdmin(admin.ModelAdmin): + list_display = ['user', 'name', 'cpf'] + search_fields = [ + 'cpf', + ] + + +admin.site.register(Investor, InvestorAdmin) diff --git a/code/investors/apps.py b/code/investors/apps.py new file mode 100644 index 000000000..e7949b168 --- /dev/null +++ b/code/investors/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class InvestorsConfig(AppConfig): + name = 'investors' diff --git a/code/investors/migrations/0001_initial.py b/code/investors/migrations/0001_initial.py new file mode 100644 index 000000000..6b867029c --- /dev/null +++ b/code/investors/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0 on 2025-12-22 16:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Investor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, verbose_name='Nome')), + ('cpf', models.CharField(max_length=15, verbose_name='CPF')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='investors', to=settings.AUTH_USER_MODEL, verbose_name='Investidores')), + ], + ), + ] diff --git a/code/investors/migrations/__init__.py b/code/investors/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/investors/models.py b/code/investors/models.py new file mode 100644 index 000000000..f1b76a7c0 --- /dev/null +++ b/code/investors/models.py @@ -0,0 +1,22 @@ +from django.db import models +from django.contrib.auth.models import User + + +class Investor(models.Model): + user = models.OneToOneField( + User, + on_delete=models.PROTECT, + related_name='investors', + verbose_name='Investidores', + ) + name = models.CharField( + max_length=150, + verbose_name='Nome', + ) + cpf = models.CharField( + max_length=15, + verbose_name='CPF', + ) + + def __str__(self): + return str(self.pk) diff --git a/code/investors/serializers.py b/code/investors/serializers.py new file mode 100644 index 000000000..aa11aa90c --- /dev/null +++ b/code/investors/serializers.py @@ -0,0 +1,8 @@ +from rest_framework.serializers import ModelSerializer +from .models import Investor + + +class InvestorSerializer(ModelSerializer): + class Meta: + model = Investor + fields = '__all__' diff --git a/code/investors/urls.py b/code/investors/urls.py new file mode 100644 index 000000000..752aedfaa --- /dev/null +++ b/code/investors/urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from .views import ( + InvestorListCreateApiView, + InvestorRetrieveUpdateDestroyApiView, +) + +urlpatterns = [ + path( + 'api/v1/investors/', + InvestorListCreateApiView.as_view(), + name='investor-list-create', + ), + path( + 'api/v1/investors//', + InvestorRetrieveUpdateDestroyApiView.as_view(), + name='investor-detail-update-delete', + ), +] diff --git a/code/investors/views.py b/code/investors/views.py new file mode 100644 index 000000000..538e3ff76 --- /dev/null +++ b/code/investors/views.py @@ -0,0 +1,17 @@ +from rest_framework.generics import ( + ListCreateAPIView, + RetrieveUpdateDestroyAPIView, +) + +from .models import Investor +from .serializers import InvestorSerializer + + +class InvestorListCreateApiView(ListCreateAPIView): + queryset = Investor.objects.all() + serializer_class = InvestorSerializer + + +class InvestorRetrieveUpdateDestroyApiView(RetrieveUpdateDestroyAPIView): + queryset = Investor.objects.all() + serializer_class = InvestorSerializer diff --git a/code/manage.py b/code/manage.py new file mode 100644 index 000000000..f2a662cfd --- /dev/null +++ b/code/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', 'core.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/code/mkdocs.yml b/code/mkdocs.yml new file mode 100644 index 000000000..6dfe8ff67 --- /dev/null +++ b/code/mkdocs.yml @@ -0,0 +1,2 @@ +site_name: Investiments Api +theme: readthedocs \ No newline at end of file diff --git a/code/pyproject.toml b/code/pyproject.toml new file mode 100644 index 000000000..ec3c72b28 --- /dev/null +++ b/code/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "investiments-api" +version = "1.0" +description = "API de investimentos" +requires-python = ">=3.11" + +[tool.ruff] +line-length = 79 +extend-exclude = ['migrations', 'manage.py', '.env'] + +[tool.ruff.lint] +preview = true +select = ['I', 'F', 'E', 'W', 'PL', 'PT'] + +[tool.ruff.format] +preview = true +quote-style = 'single' + +[tool.taskipy.tasks] +lint = 'ruff check' +lint_fix = 'ruff check --fix' +format = 'ruff format' +docs = 'mkdocs serve -a 127.0.0.1:8001' \ No newline at end of file diff --git a/code/requirements.txt b/code/requirements.txt new file mode 100644 index 000000000..262f2da19 Binary files /dev/null and b/code/requirements.txt differ diff --git a/code/services/__init__.py b/code/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/services/investiments.py b/code/services/investiments.py new file mode 100644 index 000000000..9fd0a963c --- /dev/null +++ b/code/services/investiments.py @@ -0,0 +1,27 @@ +import os +from django.core.mail import EmailMessage + + +class InvestimentService: + def send_created_investiment_email(self, data): + email = self._investiment_created_build(data=data) + email.send() + + def _investiment_created_build(self, data): + EMAILTO = os.getenv('EMAILTO') + + subject = f'Investimento nº{data.get("investiment")} criado :)' + + body = ( + f'Valor do investimento: {data.get("value")}\n' + f'Investidor: {data.get("investor")}\n' + f'Data: {data.get("timestamp_created_at")}' + ) + + email = EmailMessage( + subject=subject, + body=body, + to=[EMAILTO], + ) + + return email diff --git a/code/users/__init__.py b/code/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/users/serializers.py b/code/users/serializers.py new file mode 100644 index 000000000..75a908114 --- /dev/null +++ b/code/users/serializers.py @@ -0,0 +1,17 @@ +from django.contrib.auth.models import User +from rest_framework import serializers + + +class UserCreateSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'password'] + + def create(self, validated_data): + password = validated_data.pop('password') + user = User(**validated_data) + user.set_password(password) + user.save() + return user diff --git a/code/users/views.py b/code/users/views.py new file mode 100644 index 000000000..a78973234 --- /dev/null +++ b/code/users/views.py @@ -0,0 +1,9 @@ +from django.contrib.auth.models import User +from rest_framework.generics import ListCreateAPIView + +from .serializers import UserCreateSerializer + + +class UserListCreateView(ListCreateAPIView): + queryset = User.objects.all() + serializer_class = UserCreateSerializer