diff --git a/README.md b/README.md index ea8115e67..609004b63 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,76 @@ -# Back End Test Project +# Coderockr Backend Test -You should see this challenge as an opportunity to create an application following modern development best practices (given the stack of your choice), but also feel free to use your own architecture preferences (coding standards, code organization, third-party libraries, etc). It’s perfectly fine to use vanilla code or any framework or libraries. +A submission for Coderockr's backend development test. -## Scope +## Description -In this challenge you should build an API for an application that stores and manages investments, it should have the following features: +An investment management application exposed through a REST API that deals with 3 entities: -1. __Creation__ of an investment with an owner, a creation date and an amount. - 1. The creation date of an investment can be today or a date in the past. - 2. An investment should not be or become negative. -2. __View__ of an investment with its initial amount and expected balance. - 1. Expected balance should be the sum of the invested amount and the [gains][]. - 2. If an investment was already withdrawn then the balance must reflect the gains of that investment -3. __Withdrawal__ of a investment. - 1. The withdraw will always be the sum of the initial amount and its gains, - partial withdrawn is not supported. - 2. Withdrawals can happen in the past or today, but can't happen before the investment creation or the future. - 3. [Taxes][taxes] need to be applied to the withdrawals before showing the final value. -4. __List__ of a person's investments - 1. This list should have pagination. +- Investors +- Investments +- Withdrawals -__NOTE:__ the implementation of an interface will not be evaluated. +The system was programmed in Go, and uses the default library's http server. -### Gain Calculation +It has the following features: -The investment will pay 0.52% every month in the same day of the investment creation. +- HTTP endpoints for creating and retrieving the entities mentioned above. +- An interest applying functionality that works on a timely basis implemented through MySQL's event scheduler. +- Unit tests implemented via dependency injection +- A low number of third party libraries (i.e. github.com/go-sql-driver/mysql and github.com/go-playground/validator/v10) +- Somewhat thorough error handling, which allows for informative error messages to the end-user. -Given that the gain is paid every month, it should be treated as [compound gain][], which means that every new period (month) the amount gained will become part of the investment balance for the next payment. +## Requirements -### Taxation +In order to run the application, you'll need the following: -When money is withdrawn, tax is triggered. Taxes apply only to the profit/gain portion of the money withdrawn. For example, if the initial investment was 1000.00, the current balance is 1200.00, then the taxes will be applied to the 200.00. +- Go +- Docker -The tax percentage changes according to the age of the investment: -* If it is less than one year old, the percentage will be 22.5% (tax = 45.00). -* If it is between one and two years old, the percentage will be 18.5% (tax = 37.00). -* If older than two years, the percentage will be 15% (tax = 30.00). +Also, to view the documentation you'll need Postman. -## Requirements -1. Create project using any technology of your preference. It’s perfectly OK to use vanilla code or any framework or libraries; -2. Although you can use as many dependencies as you want, you should manage them wisely; -3. It is not necessary to send the notification emails, however, the code required for that would be welcome; -4. The API must be documented in some way. - -## Deliverables -The project source code and dependencies should be made available in GitHub. Here are the steps you should follow: -1. Fork this repository to your GitHub account (create an account if you don't have one, you will need it working with us). -2. Create a "development" branch and commit the code to it. Do not push the code to the main branch. -3. Include a README file that describes: - - Special build instructions, if any - - List of third-party libraries used and short description of why/how they were used - - A link to the API documentation. -4. Once the work is complete, create a pull request from "development" into "main" and send us the link. -5. Avoid using huge commits hiding your progress. Feel free to work on a branch and use `git rebase` to adjust your commits before submitting the final version. - -## Coding Standards -When working on the project be as clean and consistent as possible. - -## Project Deadline -Ideally you'd finish the test project in 5 days. It shouldn't take you longer than a entire week. - -## Quality Assurance -Use the following checklist to ensure high quality of the project. - -### General -- First of all, the application should run without errors. -- Are all requirements set above met? -- Is coding style consistent? -- The API is well documented? -- The API has unit tests? - -## Submission -1. A link to the Github repository. -2. Briefly describe how you decided on the tools that you used. - -## Have Fun Coding 🤘 -- This challenge description is intentionally vague in some aspects, but if you need assistance feel free to ask for help. -- If any of the seems out of your current level, you may skip it, but remember to tell us about it in the pull request. - -## Credits - -This coding challenge was inspired on [kinvoapp/kinvo-back-end-test](https://github.com/kinvoapp/kinvo-back-end-test/blob/2f17d713de739e309d17a1a74a82c3fd0e66d128/README.md) - -[gains]: #gain-calculation -[taxes]: #taxation -[interest]: #interest-calculation -[compound gain]: https://www.investopedia.com/terms/g/gain.asp +## Run Locally + +1. Go to the project directory + +```bash + cd backend-test +``` + +2. Start the database + +```bash + docker compose up -d +``` + +3. Start the server (Available at port 8080 by default) + +```bash + go run main.go +``` + +## API Documentation + +I have not served the API documentation via http due to time constraints. + +Instead, I documented the API in Postman and exported the result to the coderockr_backend_test.postman_collection.json file which is located in the projects's root. + +To use it you'll have to import it in Postman. + +I apologize for the inconveninence. + +## Closing Remarks + +I'd like to point out that this projects still lack MANY features to even be considered a first prototype. Stuff like: + +- Authentication +- Endpoints for updating and deleting entities +- Email notifications + +Although I have developed REST APIs for some of my personal projects, I had yet to tackle this matter in such a intricate manner, and so it took quite a considerable amount of time for me to finally be able to show you something. + +I'd like to thank the Coderockr team for the opportunity. This was definitely a great learning experience for me. + +Wish you guys a good one. + +Take care! diff --git a/coderockr_backend_test.postman_collection.json b/coderockr_backend_test.postman_collection.json new file mode 100644 index 000000000..cf4322a3e --- /dev/null +++ b/coderockr_backend_test.postman_collection.json @@ -0,0 +1,1383 @@ +{ + "info": { + "_postman_id": "f4c7ad08-fc49-4caa-b9d3-0695ee64a35c", + "name": "Coderockr Backend Test", + "description": "A submission for Coderockr's backend development test.\n\nAn investment managment application exposed through a REST API that deals with 3 entities:\n\n- Investors\n \n- Investments\n \n- Withdrawals", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "11087918" + }, + "item": [ + { + "name": "Investors", + "item": [ + { + "name": "Create a new investor", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cpf\": \"95130357000\", // CPF of the investor to be created\n \"name\": \"Lazlo Varga\" // Name of the investor to be created\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investors", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors" + ] + } + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cpf\": \"95130357000\", // CPF of the investor to be created\n \"name\": \"Lazlo Varga\" // Name of the investor to be created\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investors", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:51:07 GMT" + }, + { + "key": "Content-Length", + "value": "42" + } + ], + "cookie": [], + "body": "{\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n}" + }, + { + "name": "Invalid CPF", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cpf\": \"12345678900\", // CPF of the investor to be created\n \"name\": \"Lazlo Varga\" // Name of the investor to be created\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investors", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:51:59 GMT" + }, + { + "key": "Content-Length", + "value": "107" + } + ], + "cookie": [], + "body": "Invalid investor information:\nKey: 'Investor.Cpf' Error:Field validation for 'Cpf' failed on the 'cpf' tag\n" + } + ] + }, + { + "name": "Find investor by CPF", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/investors/:cpf", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors", + ":cpf" + ], + "variable": [ + { + "key": "cpf", + "value": "95130357000", + "description": "(Required) CPF of the investor to return" + } + ] + }, + "description": "Returns a single investor based on their CPF" + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/investors/:cpf", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors", + ":cpf" + ], + "variable": [ + { + "key": "cpf", + "value": "95130357000", + "description": "(Required) CPF of the investor to return" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:53:54 GMT" + }, + { + "key": "Content-Length", + "value": "42" + } + ], + "cookie": [], + "body": "{\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n}" + }, + { + "name": "Invalid CPF", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/investors/:cpf", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors", + ":cpf" + ], + "variable": [ + { + "key": "cpf", + "value": "lorem123", + "description": "(Required) CPF of the investor to return" + } + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:57:04 GMT" + }, + { + "key": "Content-Length", + "value": "12" + } + ], + "cookie": [], + "body": "Invalid CPF\n" + }, + { + "name": "Not found", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/investors/:cpf", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investors", + ":cpf" + ], + "variable": [ + { + "key": "cpf", + "value": "92087347069", + "description": "(Required) CPF of the investor to return" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:56:57 GMT" + }, + { + "key": "Content-Length", + "value": "61" + } + ], + "cookie": [], + "body": "No record of an investor with CPF 92087347069 has been found\n" + } + ] + } + ], + "description": "The `/investors` endpoints let you manage information about investors." + }, + { + "name": "Investments", + "item": [ + { + "name": "Create a new investment", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"investor_cpf\": \"95130357000\", // CPF of the investor who owns the investment\n \"initial_amount\": 1000000, // amount of money to be invested, represented in the smallest unit of the respective monetary system\n \"creation_date\": \"2025-01-01\" // date of creation of the investment\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ] + } + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"investor_cpf\": \"95130357000\", // CPF of the investor who owns the investment\n \"initial_amount\": 1000000, // amount of money to be invested, represented in the smallest unit of the respective monetary system\n \"creation_date\": \"2025-01-01\" // date of creation of the investment\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:58:00 GMT" + }, + { + "key": "Content-Length", + "value": "144" + } + ], + "cookie": [], + "body": "{\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 1000000,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n}" + }, + { + "name": "Invalid initial amount", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"investor_cpf\": \"95130357000\", // CPF of the investor who owns the investment\n \"initial_amount\": \"one thousand bucks\", // amount of money to be invested, represented in the smallest unit of the respective monetary system\n \"creation_date\": \"2025-01-01\" // date of creation of the investment\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 12:58:36 GMT" + }, + { + "key": "Content-Length", + "value": "87" + } + ], + "cookie": [], + "body": "Request body contains an invalid value for the \"initial_amount\" field (at position 80)\n" + }, + { + "name": "Non-existing investor", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"investor_cpf\": \"92087347069\", // CPF of the investor who owns the investment\n \"initial_amount\": 1000000, // amount of money to be invested, represented in the smallest unit of the respective monetary system\n \"creation_date\": \"2025-01-01\" // date of creation of the investment\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:08:16 GMT" + }, + { + "key": "Content-Length", + "value": "60" + } + ], + "cookie": [], + "body": "There are no records of an investor with CPF of 92087347069\n" + } + ] + }, + { + "name": "Find investment by ID", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1", + "description": "ID of the investment to be retrieved" + } + ] + }, + "description": "Returns a single investment based on its ID" + }, + "response": [ + { + "name": "Sucessful operation", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1", + "description": "ID of the investment to be retrieved" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:10:26 GMT" + }, + { + "key": "Content-Length", + "value": "144" + } + ], + "cookie": [], + "body": "{\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 1000000,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n}" + }, + { + "name": "Not found", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "2", + "description": "ID of the investment to be retrieved" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:11:00 GMT" + }, + { + "key": "Content-Length", + "value": "52" + } + ], + "cookie": [], + "body": "No record of an investment with id 2 has been found\n" + }, + { + "name": "Invalid ID", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "one", + "description": "ID of the investment to be retrieved" + } + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:12:52 GMT" + }, + { + "key": "Content-Length", + "value": "46" + } + ], + "cookie": [], + "body": "Investment ID of value one has a syntax error\n" + } + ] + }, + { + "name": "Filter by investor CPF", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments?investor_cpf=17988930028", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ], + "query": [ + { + "key": "investor_cpf", + "value": "17988930028" + } + ] + }, + "description": "Returns a single investment based on its ID" + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments?investor_cpf=95130357000", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ], + "query": [ + { + "key": "investor_cpf", + "value": "95130357000" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:14:32 GMT" + }, + { + "key": "Content-Length", + "value": "291" + } + ], + "cookie": [], + "body": "[\n {\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 1000000,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n },\n {\n \"id\": 4,\n \"initial_amount\": 1000000,\n \"balance\": 1000000,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n }\n]" + }, + { + "name": "Investor not found", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments?investor_cpf=17988930028", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ], + "query": [ + { + "key": "investor_cpf", + "value": "17988930028" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:45:23 GMT" + }, + { + "key": "Content-Length", + "value": "38" + } + ], + "cookie": [], + "body": "Investor of CPF 17988930028 not found\n" + }, + { + "name": "Investor has no investment", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/investments?investor_cpf=92087347069", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "investments" + ], + "query": [ + { + "key": "investor_cpf", + "value": "92087347069" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 13:46:27 GMT" + }, + { + "key": "Content-Length", + "value": "56" + } + ], + "cookie": [], + "body": "Investor of CPF 92087347069 doesn't have any investment\n" + } + ] + } + ], + "description": "The `/investments` endpoints let you manage information about investments." + }, + { + "name": "Withdrawals", + "item": [ + { + "name": "Create a new withdrawal", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"date\": \"2025-01-01\", // creation date of the withdrawal\n \"investment_id\": 1 // ID of the investment from which the withdrawal is being made\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/withdrawals", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals" + ] + } + }, + "response": [ + { + "name": "Successful operation", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"date\": \"2025-01-01\", // creation date of the withdrawal\n \"investment_id\": 1 // ID of the investment from which the withdrawal is being made\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/withdrawals", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:15:29 GMT" + }, + { + "key": "Content-Length", + "value": "234" + } + ], + "cookie": [], + "body": "{\n \"id\": 4,\n \"gross_amount\": 1000000,\n \"net_amount\": 1000000,\n \"date\": \"2025-01-01T00:00:00Z\",\n \"investment\": {\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 0,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n }\n}" + }, + { + "name": "Invalid withdrawal date", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"date\": \"2024-01-01\", // creation date of the withdrawal\n \"investment_id\": 1 // ID of the investment from which the withdrawal is being made\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/withdrawals", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals" + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:18:20 GMT" + }, + { + "key": "Content-Length", + "value": "47" + } + ], + "cookie": [], + "body": "Withdrawal date preceeds investment's creation\n" + } + ] + }, + { + "name": "Find withdrawal by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/withdrawals/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1", + "description": "(Required) ID of the withdrawal to return" + } + ] + }, + "description": "Returns a single withdrawal based on its ID" + }, + "response": [ + { + "name": "Successful response", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/withdrawals/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1", + "description": "(Required) ID of the withdrawal to return" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:21:00 GMT" + }, + { + "key": "Content-Length", + "value": "234" + } + ], + "cookie": [], + "body": "{\n \"id\": 1,\n \"gross_amount\": 1000000,\n \"net_amount\": 1000000,\n \"date\": \"2025-01-01T00:00:00Z\",\n \"investment\": {\n \"id\": 1,\n \"initial_amount\": 1000000,\n \"balance\": 0,\n \"creation_date\": \"2025-01-01T00:00:00Z\",\n \"investor\": {\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n }\n }\n}" + }, + { + "name": "Not found", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/withdrawals/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "5", + "description": "(Required) ID of the withdrawal to return" + } + ] + } + }, + "status": "Not Found", + "code": 404, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:21:48 GMT" + }, + { + "key": "Content-Length", + "value": "51" + } + ], + "cookie": [], + "body": "No record of a withdrawal with id 5 has been found\n" + }, + { + "name": "Invalid ID", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/withdrawals/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "withdrawals", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "five", + "description": "(Required) ID of the withdrawal to return" + } + ] + } + }, + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "plain", + "header": [ + { + "key": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Date", + "value": "Sat, 01 Feb 2025 14:22:24 GMT" + }, + { + "key": "Content-Length", + "value": "47" + } + ], + "cookie": [], + "body": "Withdrawal ID of value five has a syntax error\n" + } + ] + } + ], + "description": "The `/withdrawals` endpoints lets you manage information about withdrawals." + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080/api" + }, + { + "key": "investor", + "value": "{\n \"cpf\": \"95130357000\",\n \"name\": \"Lazlo Varga\"\n}", + "type": "string" + }, + { + "key": "investment", + "value": "{\n \"investor_cpf\": \"95130357000\",\n \"initial_amount\": 1000000,\n \"creation_date\": \"2025-01-01\",\n}", + "type": "string" + }, + { + "key": "investorCpf", + "value": "95130357000", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..07cde5ac7 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,12 @@ +# Using root/example as user/password credentials (not intended for production) + +services: + db: + image: mysql + restart: always + environment: + - MYSQL_ROOT_PASSWORD=example + ports: + - 3306:3306 + volumes: + - ./setup.sql:/docker-entrypoint-initdb.d/setup.sql diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..4f9561f01 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module causeurgnocchi/backend-test + +go 1.23.1 + +require ( + github.com/go-playground/validator/v10 v10.24.0 + github.com/go-sql-driver/mysql v1.8.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..225fd53b2 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers/helpers.go b/handlers/helpers.go new file mode 100644 index 000000000..e7a936fe2 --- /dev/null +++ b/handlers/helpers.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +type malformedRequest struct { + status int + msg string +} + +func (mr *malformedRequest) Error() string { + return mr.msg +} + +func decodeJsonBody(w http.ResponseWriter, r *http.Request, dst interface{}) error { + ct := r.Header.Get("Content-Type") + + if ct != "" { + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])) + if mediaType != "application/json" { + msg := "Content-Type header is not application/json" + + return &malformedRequest{status: http.StatusUnsupportedMediaType, msg: msg} + } + } + + r.Body = http.MaxBytesReader(w, r.Body, 1048576) + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := json.NewDecoder(r.Body).Decode(&dst) + + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + var timeParseError *time.ParseError + + switch { + case errors.As(err, &syntaxError): + msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset) + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + + case errors.Is(err, io.ErrUnexpectedEOF): + msg := "Request body contains badly-formed JSON" + http.Error(w, msg, http.StatusBadRequest) + + case errors.As(err, &unmarshalTypeError): + msg := fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset) + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + + case errors.As(err, &timeParseError): + return &malformedRequest{status: http.StatusBadRequest, msg: err.Error()} + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field %s") + msg := fmt.Sprintf("Request body contains unknown field %s", fieldName) + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + + case errors.Is(err, io.EOF): + msg := "Request body must not be empty" + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + + case err.Error() == "http: request body too large": + msg := "Request body must not be larger than 1MB" + return &malformedRequest{status: http.StatusRequestEntityTooLarge, msg: msg} + + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if !errors.Is(err, io.EOF) { + msg := "Request body must only contain a single JSON object" + return &malformedRequest{status: http.StatusBadRequest, msg: msg} + } + + return nil +} + +func cpfIsValid(cpf string) bool { + if len(cpf) != 11 { + return false + } + + var cpfDigits [11]int + for i, c := range cpf { + n, err := strconv.Atoi(string(c)) + if err != nil { + log.Print(err.Error()) + } + cpfDigits[i] = n + } + + sum1 := 0 + for i := 0; i < 9; i++ { + sum1 += cpfDigits[i] * (10 - i) + } + + validator1 := (sum1 * 10) % 11 + if validator1 == 10 { + validator1 = 0 + } + if validator1 != cpfDigits[9] { + return false + } + + sum2 := validator1 * 2 + for i := 0; i < 9; i++ { + sum2 += cpfDigits[i] * (11 - i) + } + + validator2 := (sum2 * 10) % 11 + if validator2 == 10 { + validator2 = 0 + } + if validator2 != cpfDigits[10] { + return false + } + + return true +} diff --git a/handlers/investment.go b/handlers/investment.go new file mode 100644 index 000000000..a7fe47937 --- /dev/null +++ b/handlers/investment.go @@ -0,0 +1,163 @@ +package handlers + +import ( + "causeurgnocchi/backend-test/models" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "time" + + "github.com/go-playground/validator/v10" + "github.com/go-sql-driver/mysql" +) + +type InvestmentHandler struct { + Investments interface { + Create(inv models.InvestmentCreationDTO) (int, error) + ById(id int) (*models.Investment, error) + ByInvestorCpf(cpf string) ([]models.Investment, error) + } +} + +func (h InvestmentHandler) CreateInvestment(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + + var dto models.InvestmentCreationDTO + + err := decodeJsonBody(w, r, &dto) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + v := validator.New() + v.RegisterValidation("notfuture", notFuture) + + err = v.Struct(dto) + if err != nil { + errs := err.(validator.ValidationErrors) + msg := fmt.Sprintf("Invalid investment information:\n%s", errs) + http.Error(w, msg, http.StatusBadRequest) + + return + } + + id, err := h.Investments.Create(dto) + if err != nil { + if err.(*mysql.MySQLError).Number == 1452 { + msg := fmt.Sprintf("There are no records of an investor with CPF of %s", dto.InvestorCPF) + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + i, err := h.Investments.ById(id) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + iJson, _ := json.Marshal(i) + + w.Header().Set("Content-Type", "application/json") + w.Write(iJson) +} + +func (h InvestmentHandler) FindInvestmentById(w http.ResponseWriter, r *http.Request) { + pv := r.PathValue("id") + + id, err := strconv.Atoi(pv) + if err != nil { + var msg string + + if errors.Is(err, strconv.ErrSyntax) { + msg = fmt.Sprintf("Investment ID of value %s has a syntax error", pv) + http.Error(w, msg, http.StatusBadRequest) + } else if errors.Is(err, strconv.ErrRange) { + msg = fmt.Sprintf("Investment ID of value %s is out of range", pv) + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + i, err := h.Investments.ById(id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("No record of an investment with id %d has been found", id) + http.Error(w, msg, http.StatusNotFound) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + iJson, _ := json.Marshal(i) + + w.Header().Set("Content-Type", "application/json") + w.Write(iJson) +} + +func (h InvestmentHandler) FilterByInvestorCpf(w http.ResponseWriter, r *http.Request) { + cpf := r.URL.Query().Get("investor_cpf") + + if !cpfIsValid(cpf) { + msg := "CPF provided is invalid" + http.Error(w, msg, http.StatusBadRequest) + return + } + + investments, err := h.Investments.ByInvestorCpf(cpf) + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("Investor of CPF %s not found", cpf) + http.Error(w, msg, http.StatusNotFound) + return + } + + if len(investments) == 0 { + msg := fmt.Sprintf("Investor of CPF %s doesn't have any investment", cpf) + http.Error(w, msg, http.StatusNotFound) + return + } + + investmentsJson, err := json.Marshal(investments) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + log.Print(err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(investmentsJson) +} + +func notFuture(fl validator.FieldLevel) bool { + today := time.Now() + creationDate := fl.Field().Interface().(models.Date) + return !creationDate.After(today) +} diff --git a/handlers/investment_test.go b/handlers/investment_test.go new file mode 100644 index 000000000..3dacf0e2e --- /dev/null +++ b/handlers/investment_test.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "bytes" + "causeurgnocchi/backend-test/models" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +var investment = &models.Investment{ + Id: 1, + InitialAmount: 1000000, + Balance: 1000000, + CreationDate: models.Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, + Investor: *investor, +} + +type mockInvestmentModel struct { +} + +func (m mockInvestmentModel) Create(models.InvestmentCreationDTO) (int, error) { + return 1, nil +} + +func (m mockInvestmentModel) ById(id int) (*models.Investment, error) { + return investment, nil +} + +func (m mockInvestmentModel) ByInvestorCpf(cpf string) ([]models.Investment, error) { + return []models.Investment{*investment}, nil +} + +func (m mockInvestmentModel) RemoveBalance(id int) error { + return nil +} + +func configInvestmentTest(t *testing.T) { + t.Helper() + + h := InvestmentHandler{Investments: mockInvestmentModel{}} + + mux = http.NewServeMux() + + mux.HandleFunc("GET /api/investments/{id}", h.FindInvestmentById) + mux.HandleFunc("GET /api/investments", h.FilterByInvestorCpf) + mux.HandleFunc("POST /api/investments", h.CreateInvestment) +} + +func TestInvestmentsCreate(t *testing.T) { + configInvestmentTest(t) + + dtoJson := []byte(fmt.Sprintf(`{"initial_amount":1000000,"creation_date":"2025-01-01","investor_cpf":"%s"}`, investor.Cpf)) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/investments", bytes.NewBuffer(dtoJson)) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + investmentJson, _ := json.Marshal(investment) + + if body := rec.Body.String(); body != string(investmentJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investmentJson), body) + } +} + +func TestInvestmentsById(t *testing.T) { + configInvestmentTest(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/investments/1", nil) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + investmentJson, _ := json.Marshal(investment) + + if body := rec.Body.String(); body != string(investmentJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investmentJson), body) + } +} + +func TestInvestmentsByInvestorCpf(t *testing.T) { + configInvestmentTest(t) + + rec := httptest.NewRecorder() + reqUrl := fmt.Sprintf("/api/investments?investor_cpf=%s", investor.Cpf) + req := httptest.NewRequest("GET", reqUrl, nil) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + investmentsJson, _ := json.Marshal([]models.Investment{*investment}) + + if body := rec.Body.String(); body != string(investmentsJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(investmentsJson), body) + } +} diff --git a/handlers/investor.go b/handlers/investor.go new file mode 100644 index 000000000..bab29ac4a --- /dev/null +++ b/handlers/investor.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "causeurgnocchi/backend-test/models" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + + "github.com/go-playground/validator/v10" + "github.com/go-sql-driver/mysql" +) + +type InvestorHandler struct { + Investors interface { + Create(invstr models.Investor) error + ByCpf(cpf string) (*models.Investor, error) + } +} + +func (h InvestorHandler) CreateInvestor(w http.ResponseWriter, r *http.Request) { + var i models.Investor + + err := decodeJsonBody(w, r, &i) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + v := validator.New() + v.RegisterValidation("cpf", func(fl validator.FieldLevel) bool { + cpf := fl.Field().String() + + return cpfIsValid(cpf) + }) + + err = v.Struct(i) + if err != nil { + errs := err.(validator.ValidationErrors) + msg := fmt.Sprintf("Invalid investor information:\n%s", errs) + http.Error(w, msg, http.StatusBadRequest) + + return + } + + err = h.Investors.Create(i) + if err != nil { + if err.(*mysql.MySQLError).Number == 1062 { + msg := "CPF provided is already being used" + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + iJson, err := json.Marshal(i) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(iJson) +} + +func (h InvestorHandler) FindInvestorByCpf(w http.ResponseWriter, r *http.Request) { + cpf := r.PathValue("cpf") + + if !cpfIsValid(cpf) { + http.Error(w, "Invalid CPF", http.StatusBadRequest) + return + } + + i, err := h.Investors.ByCpf(cpf) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("No record of an investor with CPF %s has been found", cpf) + http.Error(w, msg, http.StatusNotFound) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + iJson, _ := json.Marshal(i) + + w.Header().Set("Content-Type", "application/json") + w.Write(iJson) +} diff --git a/handlers/investor_test.go b/handlers/investor_test.go new file mode 100644 index 000000000..fdbb3146f --- /dev/null +++ b/handlers/investor_test.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "bytes" + "causeurgnocchi/backend-test/models" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +var ( + mux *http.ServeMux + + investor = &models.Investor{ + Cpf: "92087347069", + Name: "Lazlo Varga Jr", + } +) + +type mockInvestorModel struct { +} + +func (m mockInvestorModel) Create(models.Investor) error { + return nil +} + +func (m mockInvestorModel) ByCpf(cpf string) (*models.Investor, error) { + return investor, nil +} + +func configInvestorTest(t *testing.T) { + t.Helper() + + h := InvestorHandler{Investors: mockInvestorModel{}} + + mux = http.NewServeMux() + + mux.HandleFunc("GET /api/investors/{cpf}", h.FindInvestorByCpf) + mux.HandleFunc("POST /api/investors", h.CreateInvestor) +} + +func TestInvestorsCreate(t *testing.T) { + configInvestorTest(t) + + reqBody := []byte(fmt.Sprintf(`{"cpf":"%s","name":"%s"}`, investor.Cpf, investor.Name)) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/investors", bytes.NewBuffer(reqBody)) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d.\nGot %d", http.StatusOK, rec.Code) + } + + if b := rec.Body.String(); b != string(reqBody) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(reqBody), b) + } +} + +func TestInvestorsByCPF(t *testing.T) { + configInvestorTest(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/investors/"+investor.Cpf, nil) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d.\nGot %d", http.StatusOK, rec.Code) + } + + expected, _ := json.Marshal(investor) + + if b := rec.Body.String(); b != string(expected) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(expected), b) + } +} diff --git a/handlers/withdrawal.go b/handlers/withdrawal.go new file mode 100644 index 000000000..c891c5773 --- /dev/null +++ b/handlers/withdrawal.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "causeurgnocchi/backend-test/models" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/go-playground/validator/v10" +) + +type WithdrawalHandler struct { + Withdrawals interface { + Create(w models.WithdrawalCreationDTO) (int, error) + ById(id int) (*models.Withdrawal, error) + } + + Investments interface { + RemoveBalance(id int) error + } +} + +func (h WithdrawalHandler) CreateWithdrawal(w http.ResponseWriter, r *http.Request) { + var dto models.WithdrawalCreationDTO + + err := decodeJsonBody(w, r, &dto) + if err != nil { + var mr *malformedRequest + + if errors.As(err, &mr) { + http.Error(w, mr.msg, mr.status) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + v := validator.New() + + err = v.Struct(dto) + if err != nil { + errs := err.(validator.ValidationErrors) + msg := fmt.Sprintf("Invalid withdrawal information:\n%s", errs) + http.Error(w, msg, http.StatusBadRequest) + return + } + + id, err := h.Withdrawals.Create(dto) + if err != nil { + if errors.Is(err, models.ErrInvalidWithdrawalDate) { + http.Error(w, err.Error(), http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + return + } + + err = h.Investments.RemoveBalance(dto.InvestmentId) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + withdrawal, err := h.Withdrawals.ById(id) + if err != nil { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + withdrawalJson, _ := json.Marshal(withdrawal) + + w.Header().Set("Content-Type", "application/json") + w.Write(withdrawalJson) +} + +func (h WithdrawalHandler) FindWithdrawalById(w http.ResponseWriter, r *http.Request) { + pv := r.PathValue("id") + + id, err := strconv.Atoi(pv) + if err != nil { + var msg string + + if errors.Is(err, strconv.ErrSyntax) { + msg = fmt.Sprintf("Withdrawal ID of value %s has a syntax error", pv) + http.Error(w, msg, http.StatusBadRequest) + } else if errors.Is(err, strconv.ErrRange) { + msg = fmt.Sprintf("Withdrawal ID of value %s is out of range", pv) + http.Error(w, msg, http.StatusBadRequest) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + + withdrawal, err := h.Withdrawals.ById(id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("No record of a withdrawal with id %d has been found", id) + http.Error(w, msg, http.StatusNotFound) + } else { + log.Print(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + + withdrawalJson, _ := json.Marshal(withdrawal) + + w.Header().Set("Content-Type", "application/json") + w.Write(withdrawalJson) +} diff --git a/handlers/withdrawal_test.go b/handlers/withdrawal_test.go new file mode 100644 index 000000000..01038af2d --- /dev/null +++ b/handlers/withdrawal_test.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "bytes" + "causeurgnocchi/backend-test/models" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +var withdrawal = &models.Withdrawal{ + Id: 1, + GrossAmount: 1000000, + NetAmount: 1000000, + Date: models.Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, + Investment: *investment, +} + +type mockWithdrawalModel struct { +} + +func (m mockWithdrawalModel) Create(models.WithdrawalCreationDTO) (int, error) { + return 1, nil +} + +func (m mockWithdrawalModel) ById(id int) (*models.Withdrawal, error) { + return withdrawal, nil +} + +func configWithdrawalTest(t *testing.T) { + t.Helper() + + h := WithdrawalHandler{ + Withdrawals: mockWithdrawalModel{}, + Investments: mockInvestmentModel{}, + } + + mux = http.NewServeMux() + + mux.HandleFunc("GET /api/withdrawals/{id}", h.FindWithdrawalById) + mux.HandleFunc("POST /api/withdrawals", h.CreateWithdrawal) +} + +func TestWithdrawalsCreate(t *testing.T) { + configWithdrawalTest(t) + + dtoJson := []byte(`{"date":"2025-01-01","investment_id":1}`) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/withdrawals", bytes.NewBuffer(dtoJson)) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + withdrawalJson, _ := json.Marshal(withdrawal) + + if body := rec.Body.String(); body != string(withdrawalJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(withdrawalJson), body) + } +} + +func TestWithdrawalsById(t *testing.T) { + configWithdrawalTest(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/withdrawals/1", nil) + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected response code %d. Got %d", http.StatusOK, rec.Code) + } + + withdrawalJson, _ := json.Marshal(withdrawal) + + if body := rec.Body.String(); body != string(withdrawalJson) { + t.Errorf("Expected the following reponse body:\n%s.\nGot\n%s", string(withdrawalJson), body) + } +} diff --git a/main.go b/main.go new file mode 100644 index 000000000..0981424e6 --- /dev/null +++ b/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "database/sql" + "log" + "net/http" + + "causeurgnocchi/backend-test/handlers" + "causeurgnocchi/backend-test/models" +) + +func main() { + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + investorH := handlers.InvestorHandler{ + Investors: models.InvestorModel{Db: db}, + } + + investmentH := handlers.InvestmentHandler{ + Investments: models.InvestmentModel{Db: db}, + } + + withdrawalH := handlers.WithdrawalHandler{ + Withdrawals: models.WithdrawalModel{Db: db}, + Investments: models.InvestmentModel{Db: db}, + } + + http.HandleFunc("GET /api/investors/{cpf}", investorH.FindInvestorByCpf) + http.HandleFunc("POST /api/investors", investorH.CreateInvestor) + + http.HandleFunc("GET /api/investments", investmentH.FilterByInvestorCpf) + http.HandleFunc("GET /api/investments/{id}", investmentH.FindInvestmentById) + http.HandleFunc("POST /api/investments", investmentH.CreateInvestment) + + http.HandleFunc("GET /api/withdrawals/{id}", withdrawalH.FindWithdrawalById) + http.HandleFunc("POST /api/withdrawals", withdrawalH.CreateWithdrawal) + + http.ListenAndServe(":8080", nil) +} diff --git a/models/date.go b/models/date.go new file mode 100644 index 000000000..f5eafa188 --- /dev/null +++ b/models/date.go @@ -0,0 +1,25 @@ +package models + +import ( + "encoding/json" + "strings" + "time" +) + +type Date struct{ time.Time } + +func (t Date) MarshalJSON() ([]byte, error) { + return json.Marshal(t.Time) +} + +func (t *Date) UnmarshalJSON(b []byte) error { + err := json.Unmarshal(b, &t.Time) + if err != nil { + bstr := strings.Trim(string(b), `"`) + t.Time, err = time.Parse("2006-01-02", bstr) + if err != nil { + return err + } + } + return nil +} diff --git a/models/helpers.go b/models/helpers.go new file mode 100644 index 000000000..0384133cb --- /dev/null +++ b/models/helpers.go @@ -0,0 +1,11 @@ +package models + +import "database/sql" + +type database interface { + Query(query string, args ...interface{}) (*sql.Rows, error) + + QueryRow(query string, args ...interface{}) *sql.Row + + Exec(query string, args ...interface{}) (sql.Result, error) +} diff --git a/models/investment.go b/models/investment.go new file mode 100644 index 000000000..289fe0aa7 --- /dev/null +++ b/models/investment.go @@ -0,0 +1,98 @@ +package models + +type Investment struct { + Id int `json:"id"` + InitialAmount int `json:"initial_amount"` + Balance int `json:"balance"` + CreationDate Date `json:"creation_date"` + Investor Investor `json:"investor"` +} + +type InvestmentCreationDTO struct { + InitialAmount int `json:"initial_amount" validate:"required,gt=0"` + CreationDate Date `json:"creation_date" validate:"required,notfuture"` + InvestorCPF string `json:"investor_cpf" validate:"required"` +} + +type InvestmentModel struct { + Db database +} + +func (m InvestmentModel) Create(dto InvestmentCreationDTO) (int, error) { + r, err := m.Db.Exec( + "INSERT INTO investments (initial_amount, balance, creation_date, investor_cpf) VALUES (?, ?, ?, ?)", + dto.InitialAmount, + dto.InitialAmount, + dto.CreationDate.Time, + dto.InvestorCPF, + ) + if err != nil { + return -1, err + } + + id, err := r.LastInsertId() + if err != nil { + return -1, err + } + + return int(id), nil +} + +func (m InvestmentModel) ById(id int) (*Investment, error) { + var investment Investment + var cpf string + + r := m.Db.QueryRow("SELECT id, initial_amount, balance, creation_date, investor_cpf FROM investments WHERE id = ?", id) + + err := r.Scan(&investment.Id, &investment.InitialAmount, &investment.Balance, &investment.CreationDate.Time, &cpf) + if err != nil { + return nil, err + } + + investorM := &InvestorModel{Db: m.Db} + + investor, err := investorM.ByCpf(cpf) + if err != nil { + return nil, err + } + + investment.Investor = *investor + return &investment, nil +} + +func (m InvestmentModel) ByInvestorCpf(cpf string) ([]Investment, error) { + investorM := &InvestorModel{Db: m.Db} + + investor, err := investorM.ByCpf(cpf) + if err != nil { + return nil, err + } + + r, err := m.Db.Query("SELECT id, initial_amount, balance, creation_date FROM investments WHERE investor_cpf = ?", cpf) + if err != nil { + return nil, err + } + + var investements []Investment + + for r.Next() { + var i Investment + + err = r.Scan(&i.Id, &i.InitialAmount, &i.Balance, &i.CreationDate.Time) + if err != nil { + return nil, err + } + + i.Investor = *investor + + investements = append(investements, i) + } + + return investements, nil +} + +func (m InvestmentModel) RemoveBalance(id int) error { + _, err := m.Db.Exec("UPDATE investments SET balance = 0 WHERE id = ?", id) + + return err +} diff --git a/models/investment_test.go b/models/investment_test.go new file mode 100644 index 000000000..7557e2aae --- /dev/null +++ b/models/investment_test.go @@ -0,0 +1,59 @@ +package models + +import ( + "database/sql" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +func TestInvestments(t *testing.T) { + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + + investorM := InvestorModel{Db: tx} + investmentM := InvestmentModel{Db: tx} + + investor := Investor{ + Cpf: "92087347069", + Name: "Lazlo Varga Jr", + } + + investorM.Create(investor) + + investment := InvestmentCreationDTO{ + InitialAmount: 1000000, + CreationDate: Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, + InvestorCPF: "92087347069", + } + + id, err := investmentM.Create(investment) + if err != nil { + t.Errorf("Error creating investment:\n%s", err.Error()) + } + + _, err = investmentM.ById(id) + if err != nil { + t.Errorf("Error retrieving investment:\n%s", err.Error()) + } + + _, err = investmentM.ByInvestorCpf(investor.Cpf) + if err != nil { + t.Errorf("Error retrieving investments belonging to investor of CPF %s:\n%s", investor.Cpf, err.Error()) + } + + err = investmentM.RemoveBalance(id) + if err != nil { + t.Errorf("Error removing investment's balance:\n%s", err.Error()) + } +} diff --git a/models/investor.go b/models/investor.go new file mode 100644 index 000000000..edba2e086 --- /dev/null +++ b/models/investor.go @@ -0,0 +1,32 @@ +package models + +type Investor struct { + Cpf string `json:"cpf" validate:"required,cpf"` + Name string `json:"name" validate:"required"` +} + +type InvestorModel struct { + Db database +} + +func (m InvestorModel) Create(i Investor) error { + _, err := m.Db.Exec("INSERT INTO investors (cpf, name) VALUES (?, ?)", i.Cpf, i.Name) + if err != nil { + return err + } + + return nil +} + +func (m InvestorModel) ByCpf(cpf string) (*Investor, error) { + r := m.Db.QueryRow("SELECT cpf, name FROM investors where cpf = ?", cpf) + + var i Investor + + err := r.Scan(&i.Cpf, &i.Name) + if err != nil { + return nil, err + } + + return &i, nil +} diff --git a/models/investor_test.go b/models/investor_test.go new file mode 100644 index 000000000..5fece16c4 --- /dev/null +++ b/models/investor_test.go @@ -0,0 +1,39 @@ +package models + +import ( + "database/sql" + "testing" + + _ "github.com/go-sql-driver/mysql" +) + +func TestInvestors(t *testing.T) { + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + + m := &InvestorModel{Db: tx} + + i := Investor{ + Cpf: "92087347069", + Name: "Lazlo Varga Jr", + } + + err = m.Create(i) + if err != nil { + t.Errorf("Error creating investor:\n%s", err.Error()) + } + + _, err = m.ByCpf(i.Cpf) + if err != nil { + t.Errorf("Error retrieving investor:\n%s", err.Error()) + } +} diff --git a/models/withdrawal.go b/models/withdrawal.go new file mode 100644 index 000000000..cb51b53b7 --- /dev/null +++ b/models/withdrawal.go @@ -0,0 +1,91 @@ +package models + +import ( + "errors" + "math" + "strconv" +) + +type Withdrawal struct { + Id int `json:"id"` + GrossAmount int `json:"gross_amount"` + NetAmount int `json:"net_amount"` + Date Date `json:"date"` + Investment Investment `json:"investment"` +} + +type WithdrawalCreationDTO struct { + Date Date `json:"date" validate:"required"` + InvestmentId int `json:"investment_id" validate:"required"` +} + +type WithdrawalModel struct { + Db database +} + +var ErrInvalidWithdrawalDate = errors.New("Withdrawal date precedes investment's creation") + +func (m WithdrawalModel) Create(dto WithdrawalCreationDTO) (int, error) { + im := &InvestmentModel{Db: m.Db} + + i, err := im.ById(dto.InvestmentId) + if err != nil { + return -1, err + } + + if dto.Date.Before(i.CreationDate.Time) { + return -1, ErrInvalidWithdrawalDate + } + + var taxes int + gain := i.Balance - i.InitialAmount + + if i.CreationDate.Before(i.CreationDate.AddDate(1, 0, 0)) { + taxes = int(math.Floor(float64(gain) * 0.225)) + } else if i.CreationDate.Before(i.CreationDate.AddDate(2, 0, 0)) { + taxes = int(math.Floor(float64(gain) * 0.185)) + } else { + taxes = int(math.Floor(float64(gain) * 0.15)) + } + + r, err := m.Db.Exec( + "INSERT INTO withdrawals (gross_amount, net_amount, date, investment_id) VALUES (?, ?, ?, ?)", + i.Balance, + i.Balance-taxes, + dto.Date.Time, + dto.InvestmentId, + ) + if err != nil { + return -1, err + } + + id, err := r.LastInsertId() + if err != nil { + return -1, err + } + + return int(id), nil +} + +func (m WithdrawalModel) ById(id int) (*Withdrawal, error) { + var w Withdrawal + var investmentIdStr string + + r := m.Db.QueryRow("SELECT id, gross_amount, net_amount, date, investment_id FROM withdrawals WHERE id = ?", id) + + err := r.Scan(&w.Id, &w.GrossAmount, &w.NetAmount, &w.Date.Time, &investmentIdStr) + if err != nil { + return nil, err + } + + investmentM := &InvestmentModel{Db: m.Db} + investmentId, _ := strconv.Atoi(investmentIdStr) + + investment, err := investmentM.ById(investmentId) + if err != nil { + return nil, err + } + + w.Investment = *investment + return &w, nil +} diff --git a/models/withdrawal_test.go b/models/withdrawal_test.go new file mode 100644 index 000000000..f5ae2be31 --- /dev/null +++ b/models/withdrawal_test.go @@ -0,0 +1,57 @@ +package models + +import ( + "database/sql" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +func TestWithdrawalsCreate(t *testing.T) { + db, err := sql.Open("mysql", "root:example@(127.0.0.1:3306)/investments?parseTime=true") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + + investorM := InvestorModel{Db: tx} + investmentM := InvestmentModel{Db: tx} + withdrawalM := WithdrawalModel{Db: tx} + + investor := Investor{ + Cpf: "92087347069", + Name: "Lazlo Varga Jr", + } + + investorM.Create(investor) + + investment := InvestmentCreationDTO{ + InitialAmount: 1000000, + CreationDate: Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, + InvestorCPF: "92087347069", + } + + investmentId, _ := investmentM.Create(investment) + + w := WithdrawalCreationDTO{ + Date: Date{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}, + InvestmentId: investmentId, + } + + withdrawalId, err := withdrawalM.Create(w) + if err != nil { + t.Errorf("Error creating withdrawal:\n%s", err.Error()) + } + + _, err = withdrawalM.ById(withdrawalId) + if err != nil { + t.Errorf("Error getting withdrawal:\n%s", err.Error()) + } +} diff --git a/setup.sql b/setup.sql new file mode 100644 index 000000000..ad7fae95f --- /dev/null +++ b/setup.sql @@ -0,0 +1,41 @@ +DROP DATABASE IF EXISTS investments; +CREATE DATABASE investments; +USE investments; + +CREATE TABLE investors ( + cpf VARCHAR(11) NOT NULL, + name TEXT NOT NULL, + + PRIMARY KEY (cpf) +); + +CREATE TABLE investments ( + id INT AUTO_INCREMENT, + initial_amount INT NOT NULL, + balance INT NOT NULL, + creation_date DATE NOT NULL, + investor_cpf VARCHAR(11), + + PRIMARY KEY (id), + FOREIGN KEY (investor_cpf) REFERENCES investors(cpf) +); + +CREATE TABLE withdrawals ( + id INT AUTO_INCREMENT, + gross_amount INT NOT NULL, + net_amount INT NOT NULL, + date DATE NOT NULL, + investment_id INT, + + PRIMARY KEY (id), + FOREIGN KEY (investment_id) REFERENCES investments(id) +); + +CREATE EVENT apply_interest + ON SCHEDULE + EVERY 1 DAY + STARTS (TIMESTAMP(CURRENT_DATE) + INTERVAL 1 DAY) + DO + UPDATE investments + SET balance = balance * 1.0052 + WHERE IF(DAY(creation_date) > LAST_DAY(CURRENT_DATE), LAST_DAY(CURRENT_DATE), DAY(creation_date)) = DAY(CURRENT_DATE);