├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── codeql-analysis.yml │ └── main.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── SECURITY.md ├── code-gen.yml ├── docker-compose.yml ├── jest.e2e.config.js ├── jest.unit.config.js ├── nest-cli.json ├── package.json ├── public ├── favicon-96x96.png ├── favicon.ico ├── images │ └── logo.png ├── index.html ├── javascripts │ └── spectaql.min.js └── stylesheets │ └── spectaql.min.css ├── readme ├── GuiaTermoUso.pdf ├── arch-img.png ├── arquitetura-hexagonal.jpg ├── clean-architecture.pdf ├── demo.gif ├── entrada-beneficio.jpg ├── entrada-percentual.jpg ├── event-storm.png ├── excluir-caixa.jpg ├── excluir-usuario.jpg ├── lancamento-saida.jpg ├── lancamento-transferencia.jpg ├── organizacao-pastas.png ├── test-cov.png ├── wireframe.png └── youtube-icon.gif ├── scripts ├── check-deps.sh ├── check-dev-deps.sh ├── post-install.sh └── pre-start-dev.sh ├── spectacle.yaml ├── src ├── app.module.ts ├── config │ ├── env-logger.ts │ ├── env-token.ts │ ├── env.ts │ └── mongo.config.ts ├── docs │ ├── doc.controller.ts │ └── doc.module.ts ├── health-check │ ├── health-check.controller.ts │ └── health-check.module.ts ├── main.ts ├── modules │ ├── budget-box │ │ ├── application │ │ │ ├── mocks │ │ │ │ ├── budget-box-query-service.mock.ts │ │ │ │ └── budget-box-repo.mock.ts │ │ │ └── use-cases │ │ │ │ ├── add-reason-to-budget-box │ │ │ │ ├── add-reason-to-budget-box-use-case.spec.ts │ │ │ │ ├── add-reason-to-budget-box.dto.ts │ │ │ │ └── add-reason-to-budget-box.use-case.ts │ │ │ │ ├── change-budget-box-name │ │ │ │ ├── change-budget-box-name-use-case.spec.ts │ │ │ │ ├── change-budget-box-name.dto.ts │ │ │ │ └── change-budget-box-name.use-case.ts │ │ │ │ ├── change-budget-box-percentage │ │ │ │ ├── change-budget-box-percentage-use-case.spec.ts │ │ │ │ ├── change-budget-box-percentage.dto.ts │ │ │ │ └── change-budget-box-percentage.use-case.ts │ │ │ │ ├── change-reason-description │ │ │ │ ├── change-reason-description-use-case.spec.ts │ │ │ │ ├── change-reason-description.dto.ts │ │ │ │ └── change-reason-description.use-case.ts │ │ │ │ ├── create-budget-box │ │ │ │ ├── create-budget-box-use-case.spec.ts │ │ │ │ ├── create-budget-box.dto.ts │ │ │ │ └── create-budget-box.use-case.ts │ │ │ │ ├── delete-budget-box │ │ │ │ ├── delete-budget-box.dto.ts │ │ │ │ ├── delete-budget-box.use-case.spec.ts │ │ │ │ └── delete-budget-box.use-case.ts │ │ │ │ ├── get-budget-box-by-id │ │ │ │ ├── get-budget-box-by-id-use-case.spec.ts │ │ │ │ ├── get-budget-box-by-id.dto.ts │ │ │ │ └── get-budget-box-by-id.use-case.ts │ │ │ │ ├── get-budget-boxes-for-auth-user │ │ │ │ ├── get-budget-boxes-for-auth-user.-use-case.spec.ts │ │ │ │ ├── get-budget-boxes-for-auth-user.dto.ts │ │ │ │ └── get-budget-boxes-for-auth-user.use-case.ts │ │ │ │ └── remove-reason-from-budget-box │ │ │ │ ├── remove-reason-from-budget-box-dto.ts │ │ │ │ ├── remove-reason-from-budget-box-use-case.spec.ts │ │ │ │ └── remove-reason-from-budget-box.use-case.ts │ │ ├── domain │ │ │ ├── README.md │ │ │ ├── budget-box.aggregate.ts │ │ │ ├── budget-description.value-object.ts │ │ │ ├── events │ │ │ │ └── budget-box-deleted.event.ts │ │ │ ├── interfaces │ │ │ │ └── budget-box.repository.interface.ts │ │ │ ├── percentage.value-object.ts │ │ │ ├── reason-description.value-object.ts │ │ │ ├── reason.domain-entity.ts │ │ │ ├── services │ │ │ │ ├── can-allocate-percentage-to-budget-box.domain-service.ts │ │ │ │ ├── can-change-budget-box-percentage.domain-service.ts │ │ │ │ └── tests │ │ │ │ │ ├── can-allocate-percentage-to-budget-box.domain-service.spec.ts │ │ │ │ │ └── can-change-percentage-to-budget-box.domain-service.spec.ts │ │ │ ├── subscription │ │ │ │ └── after-budget-box-deleted.subscription.ts │ │ │ └── tests │ │ │ │ ├── __snapshots__ │ │ │ │ └── budget-box.aggregate.spec.ts.snap │ │ │ │ ├── budget-box.aggregate.spec.ts │ │ │ │ ├── budget-description.value-object.spec.ts │ │ │ │ ├── mock │ │ │ │ ├── budget-box.mock.ts │ │ │ │ └── reason.mock.ts │ │ │ │ ├── percentage.value-object.spec.ts │ │ │ │ ├── reason-description.value-object.spec.ts │ │ │ │ └── reason.domain-entity.spec.ts │ │ └── infra │ │ │ ├── budget-box.module.ts │ │ │ ├── budget-box.service.ts │ │ │ ├── entities │ │ │ └── budget-box.schema.ts │ │ │ ├── inputs │ │ │ ├── add-reason-to-budget-box.input.ts │ │ │ ├── budget-box-id.input.ts │ │ │ ├── change-budget-box-name.input.ts │ │ │ ├── change-budget-percentage.input.ts │ │ │ ├── change-reason-description.input.ts │ │ │ ├── create-budget-box.input.ts │ │ │ ├── delete-budget-box.input.ts │ │ │ └── remove-reason-from-budget-box.input.ts │ │ │ ├── repo │ │ │ ├── budget-box-reason.mapper.ts │ │ │ ├── budget-box.mapper.ts │ │ │ ├── budget-box.repository.ts │ │ │ └── tests │ │ │ │ └── budget-box-mapper.spec.ts │ │ │ ├── resolver │ │ │ └── budget-box.resolver.ts │ │ │ ├── services │ │ │ └── queries │ │ │ │ ├── budget-box-query.interface.ts │ │ │ │ └── budget-box-query.service.ts │ │ │ ├── tests │ │ │ ├── budget-box.mutation.ts │ │ │ ├── budget-box.query.ts │ │ │ └── budget-box.test.ts │ │ │ └── types │ │ │ ├── budget-box.type.ts │ │ │ └── reason.type.ts │ ├── shared │ │ ├── domain │ │ │ ├── budget-box-connection.interface.ts │ │ │ ├── can-create-transaction.domain-service.ts │ │ │ ├── delete-budget-box-by-user-id.domain-service.ts │ │ │ ├── delete-transactions-by-user-id.domain-service.ts │ │ │ ├── tests │ │ │ │ ├── can-create-transaction-domain-service.spec.ts │ │ │ │ ├── delete-budget-box-by-user-id-domain-service.spec.ts │ │ │ │ ├── delete-transactions-by-user-id-domain-service.spec.ts │ │ │ │ ├── mocks │ │ │ │ │ ├── budget-box-connection.mock.ts │ │ │ │ │ └── transaction-connection.mock.ts │ │ │ │ └── update-budget-box-balance-domain-service.spec.ts │ │ │ ├── transaction-connection.interface.ts │ │ │ └── update-budget-box-balance.domain-service.ts │ │ ├── index.ts │ │ ├── infra │ │ │ ├── connections │ │ │ │ ├── base-connection.interface.ts │ │ │ │ ├── budget-box-connection.ts │ │ │ │ ├── connection.ts │ │ │ │ └── transaction-connection.ts │ │ │ └── shared.module.ts │ │ ├── interfaces │ │ │ ├── budget-box-model.interface.ts │ │ │ ├── domain-service.interface.ts │ │ │ ├── entity-mock.interface.ts │ │ │ ├── reason-model.interface.ts │ │ │ ├── transaction-model.interface.ts │ │ │ └── user-model.interface.ts │ │ ├── proxies │ │ │ ├── base.proxy.ts │ │ │ └── tests │ │ │ │ └── base-proxy.spec.ts │ │ └── utils │ │ │ ├── calculate.ts │ │ │ ├── error-messages │ │ │ └── messages.ts │ │ │ └── tests │ │ │ └── calculate.spec.ts │ ├── transaction │ │ ├── application │ │ │ ├── mocks │ │ │ │ ├── transaction-query-service.mock.ts │ │ │ │ └── transaction-repo.mock.ts │ │ │ └── use-cases │ │ │ │ ├── balance-transference │ │ │ │ ├── balance-transference.dto.ts │ │ │ │ ├── balance-transference.spec.ts │ │ │ │ └── balance-transference.use-case.ts │ │ │ │ ├── create-expense │ │ │ │ ├── create-expense-use-case.spec.ts │ │ │ │ ├── create-expense.dto.ts │ │ │ │ └── create-expense.use-case.ts │ │ │ │ ├── get-transaction-by-id │ │ │ │ ├── get-transaction-by-id-use-case.spec.ts │ │ │ │ ├── get-transaction-by-id.dto.ts │ │ │ │ └── get-transaction-by-id.use-case.ts │ │ │ │ ├── get-transaction-by-user-id │ │ │ │ ├── get-transactions-by-user-id-use-case.spec.ts │ │ │ │ ├── get-transactions-by-user-id.dto.ts │ │ │ │ └── get-transactions-by-user-id.use-case.ts │ │ │ │ ├── percentage-capital-inflow-posting │ │ │ │ ├── percentage-capital-inflow-posting-use-case.spec.ts │ │ │ │ ├── percentage-capital-inflow-posting.dto.ts │ │ │ │ └── percentage-capital-inflow-posting.use-case.ts │ │ │ │ └── posting-to-benefit │ │ │ │ ├── posting-to-benefit-use-case.spec.ts │ │ │ │ ├── posting-to-benefit.dto.ts │ │ │ │ └── posting-to-benefit.use-case.ts │ │ ├── domain │ │ │ ├── README.md │ │ │ ├── attachment-path.value-object.ts │ │ │ ├── budget-box-name.value-object.ts │ │ │ ├── event │ │ │ │ └── transaction-created.event.ts │ │ │ ├── interfaces │ │ │ │ └── transaction.repository.interface.ts │ │ │ ├── services │ │ │ │ ├── can-create-benefit.proxy.ts │ │ │ │ ├── can-create-expense.proxy.ts │ │ │ │ ├── can-transfer.proxy.ts │ │ │ │ ├── create-percentage-transaction-calculation.domain-service.ts │ │ │ │ ├── create-single-calculation.domain-service.ts │ │ │ │ └── tests │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── create-percentage-transaction-calculation-domain-service.spec.ts.snap │ │ │ │ │ ├── can-create-benefit-proxy.spec.ts │ │ │ │ │ ├── can-create-expense-proxy.spec.ts │ │ │ │ │ ├── can-transfer.proxy.spec.ts │ │ │ │ │ ├── create-percentage-transaction-calculation-domain-service.spec.ts │ │ │ │ │ └── create-single-calculation-domain-service.spec.ts │ │ │ ├── subscriptions │ │ │ │ ├── after-transaction-created-subscription.spec.ts │ │ │ │ └── after-transaction-created.subscription.ts │ │ │ ├── tests │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── transaction.aggregate.spec.ts.snap │ │ │ │ ├── attachment-path.value-object.spec.ts │ │ │ │ ├── budget-box-name.value-object.spec.ts │ │ │ │ ├── mock │ │ │ │ │ └── transaction.mock.ts │ │ │ │ ├── transaction-calculations.value-object.spec.ts │ │ │ │ ├── transaction-note.value-object.spec.ts │ │ │ │ ├── transaction-reason.value-object.spec.ts │ │ │ │ ├── transaction-status.value-object.spec.ts │ │ │ │ ├── transaction-type.value-object.spec.ts │ │ │ │ └── transaction.aggregate.spec.ts │ │ │ ├── transaction-calculations.value-object.ts │ │ │ ├── transaction-note.value-object.ts │ │ │ ├── transaction-reason.value-object.ts │ │ │ ├── transaction-status.value-object.ts │ │ │ ├── transaction-type.value-object.ts │ │ │ └── transaction.aggregate.ts │ │ └── infra │ │ │ ├── entities │ │ │ └── transaction.schema.ts │ │ │ ├── inputs │ │ │ ├── balance-transference.input.ts │ │ │ ├── create-expense.input.ts │ │ │ ├── get-transaction-by-id.input.ts │ │ │ ├── percentage-capital-inflow-posting.input.ts │ │ │ └── posting-to-benefit.input.ts │ │ │ ├── repo │ │ │ ├── tests │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── transaction-mapper.spec.ts.snap │ │ │ │ └── transaction-mapper.spec.ts │ │ │ ├── transaction-calculation.mapper.ts │ │ │ ├── transaction.mapper.ts │ │ │ └── transaction.repository.ts │ │ │ ├── resolver │ │ │ └── transaction.resolver.ts │ │ │ ├── services │ │ │ └── queries │ │ │ │ ├── transaction-query.interface.ts │ │ │ │ └── transaction-query.service.ts │ │ │ ├── tests │ │ │ ├── transaction.mutation.ts │ │ │ ├── transaction.query.ts │ │ │ └── transaction.test.ts │ │ │ ├── transaction.module.ts │ │ │ ├── transaction.service.ts │ │ │ └── types │ │ │ └── transaction.types.ts │ └── user │ │ ├── application │ │ ├── mocks │ │ │ ├── user-query-service.mock.ts │ │ │ └── user-repository.mock.ts │ │ └── use-cases │ │ │ ├── delete-account │ │ │ ├── delete-account-use-case.spec.ts │ │ │ ├── delete-account.dto.ts │ │ │ └── delete-account.use-case.ts │ │ │ ├── get-user-by-id │ │ │ ├── get-user-by-id.dto.ts │ │ │ ├── get-user-by-id.spec.ts │ │ │ └── get-user-by-id.use-case.ts │ │ │ ├── signin │ │ │ ├── jwt-payload.interface.ts │ │ │ ├── signin.dto.ts │ │ │ ├── signin.use-case.spec.ts │ │ │ └── signin.use-case.ts │ │ │ └── signup │ │ │ ├── signup.dto.ts │ │ │ ├── signup.use-case.spec.ts │ │ │ └── signup.use-case.ts │ │ ├── domain │ │ ├── README.md │ │ ├── events │ │ │ └── delete-user-account.event.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ └── user.repository.interface.ts │ │ ├── ip.value-object.ts │ │ ├── subscriptions │ │ │ ├── after-delete-user-account.subscription.ts │ │ │ └── after-delete-user-subscription.spec.ts │ │ ├── term.value-object.ts │ │ ├── tests │ │ │ ├── __snapshots__ │ │ │ │ └── user.aggregate.spec.ts.snap │ │ │ ├── ip.value-object.spec.ts │ │ │ ├── mock │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── user-mock.spec.ts.snap │ │ │ │ ├── user-mock.spec.ts │ │ │ │ └── user.mock.ts │ │ │ ├── term.value-object.spec.ts │ │ │ └── user.aggregate.spec.ts │ │ └── user.aggregate.ts │ │ └── infra │ │ ├── entities │ │ └── user.schema.ts │ │ ├── inputs │ │ ├── delete-account.input.ts │ │ ├── signin.input.ts │ │ └── signup.input.ts │ │ ├── repo │ │ ├── __snapshots__ │ │ │ └── user.mapper.spec.ts.snap │ │ ├── user.mapper.spec.ts │ │ ├── user.mapper.ts │ │ └── user.repository.ts │ │ ├── resolver │ │ └── user.resolver.ts │ │ ├── services │ │ ├── decorators │ │ │ ├── get-ip.decorator.ts │ │ │ ├── get-user-agent.decorator.ts │ │ │ └── get-user.decorator.ts │ │ ├── guards │ │ │ └── jwt-auth.guard.ts │ │ ├── queries │ │ │ ├── user-query.interface.ts │ │ │ └── user-query.service.ts │ │ └── strategies │ │ │ ├── Jwt-decoded.payload.ts │ │ │ └── jwt.strategy.ts │ │ ├── tests │ │ ├── user.mutation.ts │ │ ├── user.query.ts │ │ └── user.test.ts │ │ ├── types │ │ ├── jwt-payload.type.ts │ │ ├── term.type.ts │ │ ├── user-agent.type.ts │ │ └── user.type.ts │ │ ├── user.module.ts │ │ └── user.service.ts ├── types │ ├── code-gen.types.ts │ └── schema.gql └── utils │ └── check-result.interceptor.ts ├── tsconfig.build.json ├── tsconfig.json ├── views └── index.ejs └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # default random bytes 2 | JWT_SECRET= 3 | 4 | DB_NAME= 5 | MONGO_PASSWORD= 6 | MONGO_USER= 7 | MONGO_HOST= 8 | 9 | # default 27017 10 | MONGO_PORT= 11 | 12 | # default 3000 13 | PORT= 14 | 15 | # production deactivate all logs 16 | # dev, production, test 17 | NODE_ENV= 18 | 19 | # url to server requests 20 | # default: http://localhost:3000/graphql 21 | TESTING_HOST= 22 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.types.ts 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin', 'prettier'], 8 | extends: [ 9 | 'prettier', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | '@typescript-eslint/semi': ['error', 'always'], 25 | '@typescript-eslint/member-delimiter-style': 'off', 26 | semi: ['error', 'always'], 27 | '@typescript-eslint/no-floating-promises': 'off', 28 | '@typescript-eslint/brace-style': 'off', 29 | indent: ['error', 'tab'], 30 | 'no-tabs': 0, 31 | 'space-before-function-paren': ['error', 'always'], 32 | }, 33 | }; -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | branches: [ main ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | tests: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v2 27 | 28 | # Install dependencies 29 | - name: Install dependencies 30 | run: yarn install 31 | 32 | # Runs a single command using the runners shell 33 | - name: Run all tests 34 | run: yarn test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | tsconfig.build.tsbuildinfo 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | .dccache 38 | 39 | data/ 40 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "trailingComma": "all", 5 | "useTabs": true, 6 | "semi": true, 7 | "bracketSpacing": true, 8 | "endOfLine": "lf", 9 | "embeddedLanguageFormatting": "auto", 10 | "arrowParens": "always", 11 | "requirePragma": true 12 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.fontSize": 13, 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 4 | "editor.formatOnSave": true, 5 | "editor.autoIndent": "full", 6 | "editor.detectIndentation": true, 7 | "editor.tabSize": 4, 8 | // Controls if quick suggestions should show up while typing 9 | "editor.quickSuggestions": { 10 | "other": true, 11 | "comments": true, 12 | "strings": true 13 | }, 14 | // Controls whether suggestions should be accepted on commit characters. For example, in JavaScript, the semi-colon (`;`) can be a commit character that accepts a suggestion and types that character. 15 | "editor.acceptSuggestionOnCommitCharacter": true, 16 | // Controls if suggestions should be accepted on 'Enter' - in addition to 'Tab'. Helps to avoid ambiguity between inserting new lines or accepting suggestions. The value 'smart' means only accept a suggestion with Enter when it makes a textual change 17 | "editor.acceptSuggestionOnEnter": "on", 18 | // Controls the delay in ms after which quick suggestions will show up. 19 | "editor.quickSuggestionsDelay": 100, 20 | // Controls if suggestions should automatically show up when typing trigger characters 21 | "editor.suggestOnTriggerCharacters": true, 22 | // Controls if pressing tab inserts the best suggestion and if tab cycles through other suggestions 23 | "editor.tabCompletion": "on", 24 | // Controls whether sorting favours words that appear close to the cursor 25 | "editor.suggest.localityBonus": true, 26 | // Controls how suggestions are pre-selected when showing the suggest list 27 | "editor.suggestSelection": "recentlyUsed", 28 | // Enable word based suggestions 29 | "editor.wordBasedSuggestions": true, 30 | // Enable parameter hints 31 | "editor.parameterHints.enabled": true, 32 | "cSpell.words": [ 33 | "nestjs", 34 | "signin" 35 | ], 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alessandro dev. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /code-gen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | 3 | schema: 4 | - "./src/types/schema.gql" 5 | 6 | documents: null 7 | 8 | generates: 9 | ./src/types/code-gen.types.ts: 10 | plugins: 11 | - "typescript" 12 | - "typescript-resolvers" 13 | config: 14 | preResolveTypes: true 15 | 16 | hooks: 17 | afterAllFileWrite: 18 | - yarn prettier --write 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mongo: 5 | container_name: finance_api 6 | image: mongo:4.4-focal 7 | volumes: 8 | - ./data:/data/db 9 | ports: 10 | - 27017:27017 11 | environment: 12 | MONGO_INITDB_ROOT_USERNAME: mongo 13 | MONGO_INITDB_ROOT_PASSWORD: mongo 14 | volumes: 15 | data: -------------------------------------------------------------------------------- /jest.e2e.config.js: -------------------------------------------------------------------------------- 1 | process.env.TZ = 'UTC'; 2 | 3 | module.exports = { 4 | testTimeout: 9000, 5 | roots: ['/src'], 6 | collectCoverageFrom: [ 7 | '/src/**/*.ts', 8 | '!/src/main/**', 9 | '!**/test/**', 10 | ], 11 | coverageDirectory: 'coverage', 12 | testEnvironment: 'node', 13 | transform: { 14 | '.+\\.ts$': 'ts-jest', 15 | }, 16 | testRegex: [".+\\.test\\.ts$"], 17 | moduleNameMapper: { 18 | '@/(.*)': '/src/$1', 19 | '@root/(.*)': '/src/$1', 20 | '@modules/(.*)': '/src/modules/$1', 21 | '@shared/(.*)': '/src/modules/shared/$1', 22 | '@shared-common/(.*)': '/src/modules/shared/common/$1', 23 | '@config/(.*)': '/src/config/$1', 24 | '@utils/(.*)': '/src/utils/$1', 25 | '@app/(.*)': '/src/$1', 26 | }, 27 | }; -------------------------------------------------------------------------------- /jest.unit.config.js: -------------------------------------------------------------------------------- 1 | process.env.TZ = 'UTC'; 2 | 3 | module.exports = { 4 | roots: ['/src'], 5 | collectCoverageFrom: [ 6 | '/src/modules/**/*.ts', 7 | '!/src/modules/**/infra/*.ts', 8 | '!/src/modules/**/infra/**/*.ts', 9 | '!/src/main/**', 10 | '!/src/modules/**/domain/tests/mock/**/*.ts', 11 | '!/src/modules/**/application/use-cases/**/*.dto.ts', 12 | '!**/test/**', 13 | ], 14 | coverageDirectory: 'coverage', 15 | testEnvironment: 'node', 16 | testRegex: [".+\\.spec\\.ts$"], 17 | transform: { 18 | '.+\\.ts$': 'ts-jest', 19 | }, 20 | moduleNameMapper: { 21 | '@/(.*)': '/src/$1', 22 | '@root/(.*)': '/src/$1', 23 | '@modules/(.*)': '/src/modules/$1', 24 | '@shared/(.*)': '/src/modules/shared/$1', 25 | '@shared-common/(.*)': '/src/modules/shared/common/$1', 26 | '@config/(.*)': '/src/config/$1', 27 | '@utils/(.*)': '/src/utils/$1', 28 | '@app/(.*)': '/src/$1', 29 | }, 30 | }; -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/public/favicon.ico -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/public/images/logo.png -------------------------------------------------------------------------------- /public/javascripts/spectaql.min.js: -------------------------------------------------------------------------------- 1 | "use strict";function scrollSpy(){var l="nav-scroll-active",r="nav-scroll-expand",i=null,s=[];function e(){i=null;var e=document.querySelectorAll("[data-traverse-target]");Array.prototype.forEach.call(e,function(e){s.push({id:e.id,top:e.offsetTop})})}var t=debounce(function(){e(),n()},500),n=debounce(function(){var e,t,n,o,c=function(e){for(var t=e+5,n=0;n=o.top&&(!c||t{toggleMenu(),scrollSpy()}); -------------------------------------------------------------------------------- /readme/GuiaTermoUso.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/GuiaTermoUso.pdf -------------------------------------------------------------------------------- /readme/arch-img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/arch-img.png -------------------------------------------------------------------------------- /readme/arquitetura-hexagonal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/arquitetura-hexagonal.jpg -------------------------------------------------------------------------------- /readme/clean-architecture.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/clean-architecture.pdf -------------------------------------------------------------------------------- /readme/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/demo.gif -------------------------------------------------------------------------------- /readme/entrada-beneficio.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/entrada-beneficio.jpg -------------------------------------------------------------------------------- /readme/entrada-percentual.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/entrada-percentual.jpg -------------------------------------------------------------------------------- /readme/event-storm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/event-storm.png -------------------------------------------------------------------------------- /readme/excluir-caixa.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/excluir-caixa.jpg -------------------------------------------------------------------------------- /readme/excluir-usuario.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/excluir-usuario.jpg -------------------------------------------------------------------------------- /readme/lancamento-saida.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/lancamento-saida.jpg -------------------------------------------------------------------------------- /readme/lancamento-transferencia.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/lancamento-transferencia.jpg -------------------------------------------------------------------------------- /readme/organizacao-pastas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/organizacao-pastas.png -------------------------------------------------------------------------------- /readme/test-cov.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/test-cov.png -------------------------------------------------------------------------------- /readme/wireframe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/wireframe.png -------------------------------------------------------------------------------- /readme/youtube-icon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4lessandrodev/finance-project-ddd/9f0725a83be4863db3da230e3bf9c5fa3f6c9a7d/readme/youtube-icon.gif -------------------------------------------------------------------------------- /scripts/check-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # read all dependencies name on package.json 4 | 5 | DEPS="$(cat package.json | grep -A 100 "dependencies" | grep -B 100 "\}\," | \ 6 | awk "NR>1" | sed -e "s/},//" | tr -d '":.^0-9,')"; 7 | 8 | # loop on dependencies names and save each name on a file 9 | 10 | for dep in "$(echo $DEPS)"; do 11 | echo $dep | sed -e 's/ /\n/g' > deps; 12 | done; 13 | 14 | # read each name on saved file and check version on yarn.lock 15 | 16 | while IFS= read -r line; do 17 | yarn list --depth 0 | grep $line@ 18 | done < ./deps; 19 | 20 | # delete file 21 | 22 | rm -rf ./deps 23 | -------------------------------------------------------------------------------- /scripts/check-dev-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # read all devDependencies name on package.json 4 | 5 | DEV_DEPS="$(cat package.json | grep -A 100 "devDependencies" | grep -B 100 "\}\," | \ 6 | awk "NR>1" | sed -e "s/},//" | tr -d '":.^0-9,')"; 7 | 8 | # loop on dependencies names and save each name on a file 9 | 10 | for dep in "$(echo $DEV_DEPS)"; do 11 | echo $dep | sed -e 's/ /\n/g' > deps; 12 | done; 13 | 14 | # read each name on saved file and check version on yarn.lock 15 | 16 | while IFS= read -r line; do 17 | yarn list --depth 0 | grep $line@ 18 | done < ./deps; 19 | 20 | # delete file 21 | 22 | rm -rf ./deps 23 | -------------------------------------------------------------------------------- /scripts/post-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This file check environment and run a specific command for each one 4 | 5 | # Get node environment 6 | NODEJS_ENV="$(printenv | awk '/^NODE_ENV/{print $1}' | cut -d '=' -f2)"; 7 | 8 | 9 | if [[ "$NODEJS_ENV" == "production" ]]; then 10 | 11 | # Run production commands 12 | echo "Production"; 13 | 14 | else 15 | 16 | # Run dev commands 17 | npm run generate:gql 18 | 19 | fi; 20 | -------------------------------------------------------------------------------- /scripts/pre-start-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # check if some node app is running on port 3000 and kill the process 4 | 5 | lsof -i:3000 | awk '/node/{print $2}' | awk 'NR == 1' | xargs -r kill -9 6 | -------------------------------------------------------------------------------- /spectacle.yaml: -------------------------------------------------------------------------------- 1 | spectaql: 2 | logoFile: ./public/images/logo.png 3 | 4 | introspection: 5 | url: 'http://localhost:3000/graphql' 6 | 7 | info: 8 | title: Documentação 9 | description: 10 | 'Documentação da api controle financeiro. A api tem como o objetivo garantir o controle financeiro do usuário, registrando entradas e saídas de capital e organizando cada entrada em caixas financeiros conforme o planejamento do usuário. Construída em graphql. Aqui está documentado as queries e mutations. Você pode interagir com a api através do playground no link: https://www.graphqlbin.com/v2/new alterando o endpoint para https://finance-api-ddd.herokuapp.com/graphql' 11 | contact: 12 | name: Alessandro Dev 13 | url: finance-api-ddd.herokuapp.com 14 | email: alessandroadm@live.com 15 | 16 | servers: 17 | - url: 'https://finance-api-ddd.herokuapp.com/graphql' 18 | description: Production 19 | production: true 20 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BudgetBoxModule } from '@modules/budget-box/infra/budget-box.module'; 2 | import { SharedModule } from '@modules/shared/infra/shared.module'; 3 | import { TransactionModule } from '@modules/transaction/infra/transaction.module'; 4 | import { UserModule } from '@modules/user/infra/user.module'; 5 | import { Module } from '@nestjs/common'; 6 | import { GraphQLModule } from '@nestjs/graphql'; 7 | import { MongooseModule } from '@nestjs/mongoose'; 8 | import { join } from 'path'; 9 | import { MongoDbConfig, MongoURI } from './config/mongo.config'; 10 | import { DocModule } from './docs/doc.module'; 11 | import { HealthCheckModule } from './health-check/health-check.module'; 12 | 13 | 14 | @Module({ 15 | imports: [ 16 | DocModule, 17 | HealthCheckModule, 18 | SharedModule, 19 | UserModule, 20 | BudgetBoxModule, 21 | TransactionModule, 22 | MongooseModule.forRoot(MongoURI, MongoDbConfig), 23 | GraphQLModule.forRoot({ 24 | autoSchemaFile: join(process.cwd(), 'src/types/schema.gql'), 25 | introspection: true 26 | }) 27 | ], 28 | }) 29 | export class AppModule { } 30 | -------------------------------------------------------------------------------- /src/config/env-logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "types-ddd"; 2 | 3 | export const envLogger = (name: string, info: string) => Logger.info(`${name}: ${info}`); 4 | -------------------------------------------------------------------------------- /src/config/env-token.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from "crypto"; 2 | 3 | export const DEFAULT_SECRET = process.env.NODE_ENV === 'production' ? 4 | randomBytes(42).toString('base64') : 'default-value'; 5 | -------------------------------------------------------------------------------- /src/config/env.ts: -------------------------------------------------------------------------------- 1 | import * as env from 'env-var'; 2 | const { from } = env; 3 | import { envLogger } from './env-logger'; 4 | import { DEFAULT_SECRET } from './env-token'; 5 | 6 | const envVar = from(process.env, {}, envLogger); 7 | 8 | export const JWT_SECRET = envVar.get('JWT_SECRET') 9 | .default(DEFAULT_SECRET) 10 | .required() 11 | .asString(); 12 | 13 | export const DB_NAME = envVar.get('DB_NAME') 14 | .default('finance_db') 15 | .required() 16 | .asString(); 17 | 18 | export const BUDGET_BOX_COLLECTION_NAME = envVar.get('BUDGET_BOX_COLLECTION_NAME') 19 | .default('budgetboxes') 20 | .required() 21 | .asString(); 22 | 23 | export const TRANSACTION_COLLECTION_NAME = envVar.get('TRANSACTION_COLLECTION_NAME') 24 | .default('transactions') 25 | .required() 26 | .asString(); 27 | 28 | export const MONGO_PASSWORD = envVar.get('MONGO_PASSWORD') 29 | .default('mongo') 30 | .required() 31 | .asString(); 32 | 33 | export const MONGO_USER = envVar.get('MONGO_USER') 34 | .default('root') 35 | .required() 36 | .asString(); 37 | 38 | export const MONGO_HOST = envVar.get('MONGO_HOST') 39 | .default('localhost') 40 | .required() 41 | .asString(); 42 | 43 | export const MONGO_PORT = envVar.get('MONGO_PORT') 44 | .default(27017) 45 | .required() 46 | .asPortNumber(); 47 | 48 | export const PORT = envVar.get('PORT') 49 | .default(3000) 50 | .required() 51 | .asPortNumber(); 52 | 53 | export const CURRENCY = envVar.get('CURRENCY') 54 | .default('BRL') 55 | .required() 56 | .asEnum(['BRL', 'USD', 'EUR', 'JPY']); 57 | 58 | export const TESTING_HOST = envVar.get('TESTING_HOST') 59 | .default('http://localhost:3000/graphql') 60 | .required() 61 | .asUrlString(); 62 | -------------------------------------------------------------------------------- /src/config/mongo.config.ts: -------------------------------------------------------------------------------- 1 | import { DB_NAME, MONGO_HOST, MONGO_PASSWORD, MONGO_PORT, MONGO_USER } from '@config/env'; 2 | import { MongooseModuleOptions } from '@nestjs/mongoose'; 3 | 4 | export const MongoDbConfig: MongooseModuleOptions = { 5 | useNewUrlParser: true, 6 | useUnifiedTopology: true, 7 | dbName: DB_NAME, 8 | }; 9 | 10 | const IS_PRODUCTION = process.env.NODE_ENV === 'production'; 11 | const PREFIX = IS_PRODUCTION ? 'mongodb+srv' : 'mongodb'; 12 | const PARAMS = IS_PRODUCTION ? `/${DB_NAME}?retryWrites=true&w=majority` : `:${MONGO_PORT}`; 13 | export const MongoURI = `${PREFIX}://${MONGO_USER}:${MONGO_PASSWORD}@${MONGO_HOST}${PARAMS}`; 14 | -------------------------------------------------------------------------------- /src/docs/doc.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Render } from "@nestjs/common"; 2 | 3 | @Controller('/doc') 4 | export default class DocController { 5 | 6 | @Get('/') 7 | @Render('index') 8 | async handle (): Promise { 9 | return { status: 'ok' }; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/docs/doc.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import DocController from "./doc.controller"; 3 | 4 | @Module({ 5 | controllers:[DocController] 6 | }) 7 | export class DocModule { } 8 | -------------------------------------------------------------------------------- /src/health-check/health-check.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Ip } from "@nestjs/common"; 2 | 3 | @Controller('/health-check') 4 | export default class HealthCheckController { 5 | @Get('/') 6 | async check (@Ip() ip: string): Promise { 7 | return { 8 | status: 'Ok', 9 | time: new Date(), 10 | ip: ip 11 | }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/health-check/health-check.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import HealthCheckController from "./health-check.controller"; 3 | 4 | @Module({ 5 | controllers:[HealthCheckController] 6 | }) 7 | export class HealthCheckModule { } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | import { PORT } from '@config/env'; 5 | import { NestExpressApplication } from '@nestjs/platform-express'; 6 | import { join } from 'path'; 7 | import helmet from 'helmet'; 8 | import { express as voyagerMiddleware } from 'graphql-voyager/middleware'; 9 | 10 | async function bootstrap () { 11 | const app = await NestFactory.create(AppModule); 12 | app.setViewEngine('ejs'); 13 | app.setBaseViewsDir(join(__dirname, '..', 'views')); 14 | app.useStaticAssets(join(__dirname, '..', 'public')); 15 | app.use('/voyager', voyagerMiddleware({ endpointUrl: '/graphql' })); 16 | app.use(helmet()); 17 | await app.listen(process.env.PORT ?? PORT); 18 | } 19 | bootstrap(); 20 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/mocks/budget-box-query-service.mock.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxQueryService } from "@modules/budget-box/infra/services/queries/budget-box-query.interface"; 2 | 3 | export const budgetBoxQueryServiceMock: IBudgetBoxQueryService = { 4 | getBudgetBoxByIdAndOwnerId: jest.fn(), 5 | getBudgetBoxesByOwnerId: jest.fn() 6 | }; 7 | 8 | export default budgetBoxQueryServiceMock; 9 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/mocks/budget-box-repo.mock.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxRepository } from "@modules/budget-box/domain/interfaces/budget-box.repository.interface"; 2 | 3 | export const budgetBoxMockRepo: IBudgetBoxRepository = { 4 | delete: jest.fn(), 5 | exists: jest.fn(), 6 | find: jest.fn(), 7 | findOne: jest.fn(), 8 | save: jest.fn(), 9 | }; 10 | 11 | export default budgetBoxMockRepo; 12 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/add-reason-to-budget-box/add-reason-to-budget-box.dto.ts: -------------------------------------------------------------------------------- 1 | export interface AddReasonToBudgetBoxDto { 2 | budgetBoxId: string; 3 | reasonDescription: string; 4 | ownerId: string; 5 | } 6 | 7 | export default AddReasonToBudgetBoxDto; 8 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/add-reason-to-budget-box/add-reason-to-budget-box.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxRepository } from "@modules/budget-box/domain/interfaces/budget-box.repository.interface"; 2 | import ReasonDescriptionValueObject from "@modules/budget-box/domain/reason-description.value-object"; 3 | import ReasonDomainEntity from "@modules/budget-box/domain/reason.domain-entity"; 4 | import { Inject, Injectable } from "@nestjs/common"; 5 | import { DomainId, IUseCase, Result } from "types-ddd"; 6 | import Dto from './add-reason-to-budget-box.dto'; 7 | 8 | @Injectable() 9 | export class AddReasonToBudgetBoxUseCase implements IUseCase>{ 10 | 11 | constructor ( 12 | @Inject('BudgetBoxRepository') 13 | private readonly budgetBoxRepo: IBudgetBoxRepository 14 | ){} 15 | 16 | async execute ({ budgetBoxId, reasonDescription, ownerId }: Dto) : Promise>{ 17 | try { 18 | 19 | const budgetBoxOrNull = await this.budgetBoxRepo.findOne({ id: budgetBoxId, ownerId }); 20 | 21 | if (!budgetBoxOrNull) { 22 | return Result.fail('Budget Box Not Found','NOT_FOUND'); 23 | } 24 | 25 | const reasonDescriptionOrError = ReasonDescriptionValueObject.create(reasonDescription); 26 | 27 | if (reasonDescriptionOrError.isFailure) { 28 | const message = reasonDescriptionOrError.errorValue(); 29 | return Result.fail(message); 30 | } 31 | 32 | const description = reasonDescriptionOrError.getResult(); 33 | const ID = DomainId.create(); 34 | const reasonEntityOrError = ReasonDomainEntity.create({ ID, description }); 35 | 36 | const reasonEntity = reasonEntityOrError.getResult(); 37 | const budgetBox = budgetBoxOrNull; 38 | 39 | budgetBox.addReason(reasonEntity); 40 | 41 | await this.budgetBoxRepo.save(budgetBox); 42 | 43 | return Result.success(); 44 | 45 | } catch (error) { 46 | return Result.fail('Internal Server Error on Add Reason To Budget Box', 'INTERNAL_SERVER_ERROR'); 47 | } 48 | }; 49 | } 50 | 51 | export default AddReasonToBudgetBoxUseCase; 52 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/change-budget-box-name/change-budget-box-name.dto.ts: -------------------------------------------------------------------------------- 1 | export interface ChangeBudgetBoxNameDto { 2 | ownerId: string; 3 | description: string; 4 | budgetBoxId: string; 5 | } 6 | 7 | export default ChangeBudgetBoxNameDto; 8 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/change-budget-box-name/change-budget-box-name.use-case.ts: -------------------------------------------------------------------------------- 1 | import BudgetDescriptionValueObject from "@modules/budget-box/domain/budget-description.value-object"; 2 | import { IBudgetBoxRepository } from "@modules/budget-box/domain/interfaces/budget-box.repository.interface"; 3 | import { Inject } from "@nestjs/common"; 4 | import { IUseCase, Result } from "types-ddd"; 5 | import Dto from "./change-budget-box-name.dto"; 6 | 7 | export class ChangeBudgetBoxNameUseCase implements IUseCase>{ 8 | 9 | constructor ( 10 | @Inject('BudgetBoxRepository') 11 | private readonly budgetBoxRepo: IBudgetBoxRepository 12 | ){} 13 | 14 | async execute ({ ownerId, description, budgetBoxId: id }: Dto): Promise>{ 15 | try { 16 | const budgetBoxOrNull = await this.budgetBoxRepo.findOne({ id, ownerId }); 17 | 18 | if (!budgetBoxOrNull) { 19 | return Result.fail('Budget Box Not Found', 'NOT_FOUND'); 20 | } 21 | 22 | const descriptionOrError = BudgetDescriptionValueObject.create(description); 23 | 24 | if (descriptionOrError.isFailure) { 25 | const message = descriptionOrError.errorValue(); 26 | return Result.fail(message); 27 | } 28 | 29 | const budgetBox = budgetBoxOrNull; 30 | const descriptionVo = descriptionOrError.getResult(); 31 | 32 | budgetBox.changeDescription(descriptionVo); 33 | await this.budgetBoxRepo.save(budgetBox); 34 | return Result.success(); 35 | 36 | } catch (error) { 37 | return Result.fail('Internal Server Error on Change Budget Box Name Use Case', 'INTERNAL_SERVER_ERROR'); 38 | } 39 | }; 40 | } 41 | 42 | export default ChangeBudgetBoxNameUseCase; 43 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/change-budget-box-percentage/change-budget-box-percentage.dto.ts: -------------------------------------------------------------------------------- 1 | export interface ChangeBudgetBoxPercentageDto { 2 | ownerId: string; 3 | budgetPercentage: number; 4 | budgetBoxId: string; 5 | } 6 | 7 | export default ChangeBudgetBoxPercentageDto; 8 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/change-budget-box-percentage/change-budget-box-percentage.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxRepository } from "@modules/budget-box/domain/interfaces/budget-box.repository.interface"; 2 | import PercentageValueObject from "@modules/budget-box/domain/percentage.value-object"; 3 | import { Inject } from "@nestjs/common"; 4 | import { IUseCase, Result } from "types-ddd"; 5 | import Dto from "./change-budget-box-percentage.dto"; 6 | 7 | export class ChangeBudgetBoxPercentageUseCase implements IUseCase>{ 8 | 9 | constructor ( 10 | @Inject('BudgetBoxRepository') 11 | private readonly budgetRepo: IBudgetBoxRepository 12 | ){} 13 | 14 | async execute ({ budgetBoxId: id, budgetPercentage, ownerId }: Dto) : Promise> { 15 | try { 16 | const budgetBoxOrNull = await this.budgetRepo.findOne({ id, ownerId }); 17 | 18 | if (!budgetBoxOrNull) { 19 | return Result.fail('Budget Box Not Found', 'NOT_FOUND'); 20 | } 21 | 22 | const budgetBox = budgetBoxOrNull; 23 | 24 | const percentageOrError = PercentageValueObject.create(budgetPercentage); 25 | 26 | if (percentageOrError.isFailure) { 27 | return Result.fail(percentageOrError.error); 28 | } 29 | 30 | const percentage = percentageOrError.getResult(); 31 | 32 | budgetBox.changePercentage(percentage); 33 | 34 | await this.budgetRepo.save(budgetBox); 35 | 36 | return Result.success(); 37 | } catch (error) { 38 | return Result.fail( 39 | 'Internal Server Error on Change Budget Box Percentage Use Case', 'INTERNAL_SERVER_ERROR' 40 | ); 41 | } 42 | }; 43 | } 44 | 45 | export default ChangeBudgetBoxPercentageUseCase; 46 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/change-reason-description/change-reason-description.dto.ts: -------------------------------------------------------------------------------- 1 | export interface ChangeReasonDescriptionDto { 2 | budgetBoxId: string; 3 | reasonDescription: string; 4 | ownerId: string; 5 | reasonId: string; 6 | } 7 | 8 | export default ChangeReasonDescriptionDto; 9 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/change-reason-description/change-reason-description.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxRepository } from "@modules/budget-box/domain/interfaces/budget-box.repository.interface"; 2 | import ReasonDescriptionValueObject from "@modules/budget-box/domain/reason-description.value-object"; 3 | import { Inject, Injectable } from "@nestjs/common"; 4 | import { DomainId, IUseCase, Result } from "types-ddd"; 5 | import Dto from './change-reason-description.dto'; 6 | 7 | @Injectable() 8 | export class ChangeReasonDescriptionUseCase implements IUseCase>{ 9 | 10 | constructor ( 11 | @Inject('BudgetBoxRepository') 12 | private readonly budgetBoxRepo: IBudgetBoxRepository 13 | ){} 14 | 15 | async execute ({ budgetBoxId, reasonDescription, ownerId, reasonId }: Dto) : Promise>{ 16 | try { 17 | 18 | const budgetBoxOrNull = await this.budgetBoxRepo.findOne({ id: budgetBoxId, ownerId }); 19 | 20 | if (!budgetBoxOrNull) { 21 | return Result.fail('Budget Box Not Found','NOT_FOUND'); 22 | } 23 | 24 | const reasonDescriptionOrError = ReasonDescriptionValueObject.create(reasonDescription); 25 | 26 | if (reasonDescriptionOrError.isFailure) { 27 | const message = reasonDescriptionOrError.errorValue(); 28 | return Result.fail(message); 29 | } 30 | 31 | const description = reasonDescriptionOrError.getResult(); 32 | const ID = DomainId.create(reasonId); 33 | 34 | const budgetBox = budgetBoxOrNull; 35 | 36 | const updated = budgetBox.changeReasonDescription(ID, description); 37 | 38 | if (!updated) { 39 | return Result.fail('Reason does not found on Budget Box','NOT_FOUND'); 40 | } 41 | 42 | await this.budgetBoxRepo.save(budgetBox); 43 | 44 | return Result.success(); 45 | 46 | } catch (error) { 47 | return Result.fail('Internal Server Error on Change Reason Description Use Case', 'INTERNAL_SERVER_ERROR'); 48 | } 49 | }; 50 | } 51 | 52 | export default ChangeReasonDescriptionUseCase; 53 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/create-budget-box/create-budget-box.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateBudgetBoxDto { 2 | ownerId!: string; 3 | description!: string; 4 | isPercentage!: boolean; 5 | budgetPercentage!: number; 6 | } 7 | 8 | export default CreateBudgetBoxDto; 9 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/delete-budget-box/delete-budget-box.dto.ts: -------------------------------------------------------------------------------- 1 | export interface DeleteBudgetBoxDto { 2 | userId: string; 3 | budgetBoxId: string; 4 | } 5 | 6 | export default DeleteBudgetBoxDto; 7 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/delete-budget-box/delete-budget-box.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxRepository } from '@modules/budget-box/domain/interfaces/budget-box.repository.interface'; 2 | import { Inject } from '@nestjs/common'; 3 | import { IUseCase, Result } from 'types-ddd'; 4 | import DeleteBudgetBoxDto from './delete-budget-box.dto'; 5 | 6 | export class DeleteBudgetBoxUseCase implements IUseCase>{ 7 | constructor ( 8 | @Inject('BudgetBoxRepository') 9 | private readonly budgetBoxRepo: IBudgetBoxRepository 10 | ) { } 11 | 12 | async execute ({ budgetBoxId: id, userId: ownerId }: DeleteBudgetBoxDto): Promise> { 13 | try { 14 | 15 | const budgetBoxOrNull = await this.budgetBoxRepo.findOne({ id, ownerId }); 16 | 17 | if (!budgetBoxOrNull) { 18 | return Result.fail('Budget Box Does Not Exists', 'NOT_FOUND'); 19 | } 20 | 21 | const budgetBox = budgetBoxOrNull; 22 | 23 | const hasNotBalance = budgetBox.balanceAvailable.isEqualTo(0); 24 | 25 | if (!hasNotBalance) { 26 | return Result.fail('The budget box must have a zero balance', 'CONFLICT'); 27 | } 28 | 29 | budgetBox.delete(); 30 | 31 | await this.budgetBoxRepo.delete({ id }); 32 | 33 | return Result.success(); 34 | 35 | } catch (error) { 36 | return Result.fail('Internal Server Error on Delete Budget BoxUse Case', 'INTERNAL_SERVER_ERROR'); 37 | } 38 | } 39 | } 40 | 41 | export default DeleteBudgetBoxUseCase; 42 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/get-budget-box-by-id/get-budget-box-by-id.dto.ts: -------------------------------------------------------------------------------- 1 | export interface GetBudgetBoxByIdDto { 2 | ownerId: string; 3 | budgetBoxId: string; 4 | } 5 | 6 | export default GetBudgetBoxByIdDto; 7 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/get-budget-box-by-id/get-budget-box-by-id.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxQueryService } from "@modules/budget-box/infra/services/queries/budget-box-query.interface"; 2 | import { IBudgetBox } from "@modules/shared"; 3 | import { Inject } from "@nestjs/common"; 4 | import { IUseCase, Result } from "types-ddd"; 5 | import Dto from '@modules/budget-box/application/use-cases/get-budget-box-by-id/get-budget-box-by-id.dto'; 6 | 7 | export class GetBudgetBoxByIdUseCase implements IUseCase>{ 8 | 9 | constructor ( 10 | @Inject('BudgetBoxQueryService') 11 | private readonly budgetRepo: IBudgetBoxQueryService 12 | ){} 13 | 14 | async execute ({ budgetBoxId, ownerId }: Dto): Promise> { 15 | try { 16 | 17 | const budgetBoxOrNull = await this.budgetRepo.getBudgetBoxByIdAndOwnerId({ id: budgetBoxId, ownerId }); 18 | 19 | if (!budgetBoxOrNull) { 20 | return Result.fail('Budget Box Not Found', 'NOT_FOUND'); 21 | } 22 | 23 | return Result.ok(budgetBoxOrNull); 24 | } catch (error) { 25 | return Result.fail('Internal Server Error on Get Budget Box By Id Use Case', 'INTERNAL_SERVER_ERROR'); 26 | } 27 | }; 28 | } 29 | 30 | export default GetBudgetBoxByIdUseCase; 31 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/get-budget-boxes-for-auth-user/get-budget-boxes-for-auth-user.dto.ts: -------------------------------------------------------------------------------- 1 | export interface GetBudgetBoxesForAuthUserDto { 2 | ownerId: string; 3 | } 4 | 5 | export default GetBudgetBoxesForAuthUserDto; 6 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/get-budget-boxes-for-auth-user/get-budget-boxes-for-auth-user.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxQueryService } from "@modules/budget-box/infra/services/queries/budget-box-query.interface"; 2 | import { IBudgetBox } from "@modules/shared"; 3 | import { Inject, Injectable } from "@nestjs/common"; 4 | import { IUseCase, Result } from "types-ddd"; 5 | import Dto from './get-budget-boxes-for-auth-user.dto'; 6 | 7 | @Injectable() 8 | export class GetBudgetBoxesForAuthUserUseCase implements IUseCase> { 9 | 10 | constructor ( 11 | @Inject('BudgetBoxQueryService') 12 | private readonly budgetBoxQueryService: IBudgetBoxQueryService 13 | ){} 14 | 15 | async execute ({ ownerId }: Dto): Promise> { 16 | try { 17 | const budgetBoxesFound = await this 18 | .budgetBoxQueryService 19 | .getBudgetBoxesByOwnerId(ownerId); 20 | 21 | return Result.ok(budgetBoxesFound); 22 | 23 | } catch (error) { 24 | return Result.fail('Internal Server Error on Get Budget Boxes For Auth User UseCase', 'INTERNAL_SERVER_ERROR'); 25 | } 26 | } 27 | } 28 | 29 | export default GetBudgetBoxesForAuthUserUseCase; 30 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/remove-reason-from-budget-box/remove-reason-from-budget-box-dto.ts: -------------------------------------------------------------------------------- 1 | export interface RemoveReasonFromBudgetBoxDto { 2 | budgetBoxId: string; 3 | reasonId: string; 4 | ownerId: string; 5 | } 6 | 7 | export default RemoveReasonFromBudgetBoxDto; 8 | -------------------------------------------------------------------------------- /src/modules/budget-box/application/use-cases/remove-reason-from-budget-box/remove-reason-from-budget-box.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxRepository } from "@modules/budget-box/domain/interfaces/budget-box.repository.interface"; 2 | import { Inject } from "@nestjs/common"; 3 | import { DomainId, IUseCase, Result } from "types-ddd"; 4 | import Dto from "./remove-reason-from-budget-box-dto"; 5 | 6 | export class RemoveReasonFromBudgetBoxUseCase implements IUseCase>{ 7 | 8 | constructor ( 9 | @Inject('BudgetBoxRepository') 10 | private readonly budgetBoxRepo: IBudgetBoxRepository 11 | ){} 12 | 13 | async execute ({budgetBoxId:id, ownerId, reasonId }: Dto) : Promise> { 14 | try { 15 | 16 | const budgetBoxOrNull = await this.budgetBoxRepo.findOne({ id, ownerId }); 17 | 18 | if (!budgetBoxOrNull) { 19 | return Result.fail('Budget Box Not Found', 'NOT_FOUND'); 20 | } 21 | 22 | const budgetBox = budgetBoxOrNull; 23 | 24 | const removed = budgetBox.removeReasonById(DomainId.create(reasonId)); 25 | 26 | if (!removed) { 27 | return Result.fail('Reason not found on budget box', 'NOT_FOUND'); 28 | } 29 | 30 | await this.budgetBoxRepo.save(budgetBox); 31 | 32 | return Result.success(); 33 | 34 | } catch (error) { 35 | return Result.fail('Internal Server Error on Remove Reason from Budget Box', 'INTERNAL_SERVER_ERROR'); 36 | } 37 | }; 38 | } 39 | 40 | export default RemoveReasonFromBudgetBoxUseCase; 41 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/README.md: -------------------------------------------------------------------------------- 1 | # BudgetBox - Aggregate 2 | 3 | --- 4 | 5 | São os caixas financeiros os quais o usuário irá cadastrar e definir sua meta em percentual. 6 | Podendo também ser categorizado como um benefício. 7 | 8 | Caso este esteja definido como benefício o percentual não pode ser diferente de 100%. 9 | 10 | A soma de todos os caixas cadastrados e definidos como percentual não pode ultrapassar 100%. 11 | bem como nenhum caixa percentual individual não pode ter a meta superior a 100%. 12 | 13 | Cada caixa possui motivos que são pertinentes apenas a ele mesmo. 14 | 15 | Os motivos tem como objetivo identificar a finalidade do lançamento. 16 | 17 | A cada lançamento de entrada este receberá o montante de acordo com o percentual definido: 18 | 19 | exemplo: 20 | 21 | - Lançamento: Entrada; 22 | - Valor: R$ 100; 23 | - Percentual definido no caixa: 80; 24 | - Montante a ser creditado no caixa: 80; 25 | 26 | ```json 27 | { 28 | "id": "uuid", 29 | "owner-id": "uuid", 30 | "description": "valid_description", 31 | "balance-available": 1000, 32 | "is-percentual": true, 33 | "budget-percentage": 80, 34 | "reasons": [ 35 | { 36 | "id": "uuid", 37 | "description": "valid_descripíont" 38 | } 39 | ] 40 | } 41 | ``` 42 | 43 | ### Structure 44 | 45 | - Budgetbox: Aggregate - Ok 46 | - reason-description: Value Object - Ok 47 | - budget-description: Value Object - Ok 48 | - budget-id: Value Object - Ok 49 | - budget-percentage: Value Object - Ok 50 | - reason: Entity - Ok 51 | - reason-id: Value Object - Ok 52 | 53 | ## Fluxos 54 | #### Excluir um caixa financeiro 55 | 56 | imagem 57 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/budget-description.value-object.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ErrorMessages } from '@modules/shared'; 3 | import { Result, ValueObject } from 'types-ddd'; 4 | 5 | export const BUDGET_DESCRIPTION_MAX_LENGTH = 30; 6 | export const BUDGET_DESCRIPTION_MIN_LENGTH = 1; 7 | 8 | export interface BudgetDescriptionValueObjectProps { 9 | value: string; 10 | } 11 | 12 | export class BudgetDescriptionValueObject extends ValueObject { 13 | private constructor (props: BudgetDescriptionValueObjectProps) { 14 | super(props); 15 | } 16 | 17 | get value (): string { 18 | return this.props.value; 19 | } 20 | 21 | static isValidValue (value: string): boolean { 22 | 23 | const descriptionLength = value.trim().length; 24 | 25 | const isValid = descriptionLength >= BUDGET_DESCRIPTION_MIN_LENGTH && 26 | descriptionLength <= BUDGET_DESCRIPTION_MAX_LENGTH; 27 | return isValid; 28 | } 29 | 30 | public static create ( 31 | description: string, 32 | ): Result { 33 | 34 | const isValidLength = BudgetDescriptionValueObject.isValidValue(description); 35 | 36 | if (!isValidLength) { 37 | return Result.fail( 38 | ErrorMessages.INVALID_BUDGET_DESCRIPTION_LENGTH, 39 | ); 40 | } 41 | 42 | return Result.ok( 43 | new BudgetDescriptionValueObject({ value: description.toLowerCase() }), 44 | ); 45 | } 46 | } 47 | 48 | export default BudgetDescriptionValueObject; 49 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/events/budget-box-deleted.event.ts: -------------------------------------------------------------------------------- 1 | import { IDomainEvent, UniqueEntityID } from "types-ddd"; 2 | import BudgetBoxAggregate from "../budget-box.aggregate"; 3 | 4 | export class BudgetBoxDeletedEvent implements IDomainEvent { 5 | public dateTimeOccurred: Date; 6 | public budgetBox: BudgetBoxAggregate; 7 | 8 | constructor (budgetBox: BudgetBoxAggregate) { 9 | this.budgetBox = budgetBox; 10 | this.dateTimeOccurred = new Date(); 11 | } 12 | 13 | getAggregateId (): UniqueEntityID { 14 | return this.budgetBox.id.value; 15 | } 16 | 17 | } 18 | 19 | export default BudgetBoxDeletedEvent; 20 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/interfaces/budget-box.repository.interface.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBox } from "@shared/index"; 2 | import { IBaseRepository } from "types-ddd"; 3 | import BudgetBoxAggregate from "../budget-box.aggregate"; 4 | 5 | export type IBudgetBoxRepository = IBaseRepository; 6 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/percentage.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { ValueObject, Result } from 'types-ddd'; 3 | 4 | export const BUDGET_PERCENTAGE_MAX_VALUE = 100; 5 | export const BUDGET_PERCENTAGE_MIN_VALUE = 0; 6 | export const DEFAULT_BUDGET_PERCENTAGE_VALUE = 100; 7 | 8 | export interface PercentageValueObjectProps { 9 | value: number; 10 | } 11 | 12 | export class PercentageValueObject extends ValueObject { 13 | private constructor (props: PercentageValueObjectProps) { 14 | super(props); 15 | } 16 | 17 | get value (): number { 18 | return this.props.value; 19 | } 20 | 21 | static isValidValue (value: number): boolean { 22 | const isValidRange = 23 | value >= BUDGET_PERCENTAGE_MIN_VALUE && 24 | value <= BUDGET_PERCENTAGE_MAX_VALUE; 25 | return isValidRange; 26 | } 27 | 28 | public static create (value: number): Result { 29 | 30 | const isValidRange = PercentageValueObject.isValidValue(value); 31 | 32 | if (!isValidRange) { 33 | return Result.fail( 34 | ErrorMessages.INVALID_PERCENTAGE_VALUE, 35 | ); 36 | } 37 | 38 | return Result.ok( 39 | new PercentageValueObject({ value }), 40 | ); 41 | } 42 | } 43 | 44 | export default PercentageValueObject; 45 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/reason-description.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { ValueObject, Result } from 'types-ddd'; 3 | 4 | export const REASON_DESCRIPTION_MAX_LENGTH = 30; 5 | export const REASON_DESCRIPTION_MIN_LENGTH = 1; 6 | export interface ReasonDescriptionValueObjectProps { 7 | value: string; 8 | } 9 | 10 | export class ReasonDescriptionValueObject extends ValueObject { 11 | private constructor (props: ReasonDescriptionValueObjectProps) { 12 | super(props); 13 | } 14 | 15 | get value (): string { 16 | return this.props.value; 17 | } 18 | 19 | public static isValidValue (description: string): boolean { 20 | const descriptionLength = description.trim().length; 21 | 22 | const isValidLength = descriptionLength >= REASON_DESCRIPTION_MIN_LENGTH && 23 | descriptionLength <= REASON_DESCRIPTION_MAX_LENGTH; 24 | 25 | return isValidLength; 26 | } 27 | 28 | public static create (description: string): Result { 29 | 30 | const isValidLength = ReasonDescriptionValueObject.isValidValue(description); 31 | 32 | if (!isValidLength) { 33 | return Result.fail( 34 | ErrorMessages.INVALID_REASON_DESCRIPTION_LENGTH, 35 | ); 36 | } 37 | return Result.ok( 38 | new ReasonDescriptionValueObject({ value: description.toLowerCase() }), 39 | ); 40 | } 41 | } 42 | 43 | export default ReasonDescriptionValueObject; 44 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/reason.domain-entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseDomainEntity, Entity, Result } from 'types-ddd'; 2 | import { ReasonDescriptionValueObject } from './reason-description.value-object'; 3 | 4 | export interface ReasonProps extends BaseDomainEntity { 5 | description: ReasonDescriptionValueObject; 6 | } 7 | 8 | /** 9 | * @var description: `ReasonDescriptionValueObject` 10 | */ 11 | export class ReasonDomainEntity extends Entity { 12 | private constructor (props: ReasonProps) { 13 | super(props, ReasonDomainEntity.name); 14 | } 15 | 16 | get description (): ReasonDescriptionValueObject { 17 | return this.props.description; 18 | } 19 | 20 | public static create (props: ReasonProps): Result { 21 | return Result.ok(new ReasonDomainEntity(props)); 22 | } 23 | } 24 | 25 | export default ReasonDomainEntity; 26 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/services/can-allocate-percentage-to-budget-box.domain-service.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from "@nestjs/common"; 2 | import { Result } from "types-ddd"; 3 | import { IDomainService } from "@modules/shared/interfaces/domain-service.interface"; 4 | import { IBudgetBoxQueryService } from "@modules/budget-box/infra/services/queries/budget-box-query.interface"; 5 | 6 | interface Dto { 7 | ownerId: string; 8 | budgetPercentage: number; 9 | isPercentage: boolean; 10 | } 11 | 12 | export class CanAllocatePercentageToBudgetBoxDomainService implements IDomainService>{ 13 | constructor ( 14 | @Inject('BudgetBoxQueryService') 15 | private readonly connection: IBudgetBoxQueryService 16 | ) { } 17 | async execute ({ ownerId, budgetPercentage, isPercentage }: Dto): Promise> { 18 | const maxPercentage = 100; 19 | const initialValue = 0; 20 | 21 | if(!isPercentage) return Result.ok(!isPercentage); 22 | 23 | const budgetBoxes = await this.connection.getBudgetBoxesByOwnerId(ownerId); 24 | 25 | const totalPercentageAllocated = budgetBoxes.reduce((total, budgetBox) => { 26 | if (budgetBox.isPercentage) { 27 | return total + budgetBox.budgetPercentage; 28 | } 29 | return total; 30 | }, initialValue); 31 | 32 | const totalSum = totalPercentageAllocated + budgetPercentage; 33 | 34 | const canAllocate = totalSum <= maxPercentage; 35 | 36 | if (canAllocate) { 37 | return Result.ok(canAllocate); 38 | } 39 | 40 | const available = maxPercentage - totalPercentageAllocated; 41 | return Result.fail(`Could not allocate percentage to budget-box. ${totalPercentageAllocated}% already allocated. Available ${available}%`); 42 | } 43 | } 44 | 45 | export default CanAllocatePercentageToBudgetBoxDomainService; 46 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/services/can-change-budget-box-percentage.domain-service.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from "@nestjs/common"; 2 | import { Result } from "types-ddd"; 3 | import { IDomainService } from "@modules/shared/interfaces/domain-service.interface"; 4 | import { IBudgetBoxQueryService } from "@modules/budget-box/infra/services/queries/budget-box-query.interface"; 5 | 6 | interface Dto { 7 | ownerId: string; 8 | budgetPercentage: number; 9 | budgetBoxId: string; 10 | } 11 | 12 | export class CanChangeBudgetBoxPercentageDomainService implements IDomainService>{ 13 | constructor ( 14 | @Inject('BudgetBoxQueryService') 15 | private readonly connection: IBudgetBoxQueryService 16 | ) { } 17 | async execute ({ ownerId, budgetPercentage, budgetBoxId }: Dto): Promise> { 18 | const maxPercentage = 100; 19 | const initialValue = 0; 20 | 21 | const budgetBoxes = await this.connection.getBudgetBoxesByOwnerId(ownerId); 22 | 23 | const budgetBoxToChange = budgetBoxes.find( 24 | (budgetBox) => budgetBox.id === budgetBoxId && budgetBox.isPercentage 25 | ); 26 | 27 | if (!budgetBoxToChange) { 28 | return Result.fail('Could not find budget box', 'NOT_FOUND'); 29 | } 30 | 31 | const totalPercentage = budgetBoxes.reduce((total, budgetBox) => { 32 | if (budgetBox.isPercentage) { 33 | return total + budgetBox.budgetPercentage; 34 | } 35 | return total; 36 | }, initialValue); 37 | 38 | const oldPercentageValue = budgetBoxToChange.budgetPercentage; 39 | 40 | const totalSum = ((totalPercentage + budgetPercentage) - oldPercentageValue); 41 | 42 | const canAllocate = totalSum <= maxPercentage; 43 | 44 | if (canAllocate) { 45 | return Result.ok(canAllocate); 46 | } 47 | 48 | const available = maxPercentage - totalPercentage; 49 | return Result.fail(`Could not allocate percentage to budget-box. ${totalPercentage}% already allocated. Available ${available}%`); 50 | } 51 | } 52 | 53 | export default CanChangeBudgetBoxPercentageDomainService; 54 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/subscription/after-budget-box-deleted.subscription.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvents, IHandle, Logger } from "types-ddd"; 2 | import BudgetBoxDeletedEvent from "../events/budget-box-deleted.event"; 3 | 4 | export class AfterBudgetBoxDeleted implements IHandle{ 5 | constructor () { 6 | this.setupSubscriptions(); 7 | } 8 | 9 | setupSubscriptions (): void { 10 | DomainEvents.register( 11 | (event) => this.dispatch(Object.assign(event)), 12 | BudgetBoxDeletedEvent.name 13 | ); 14 | } 15 | 16 | async dispatch (event: BudgetBoxDeletedEvent): Promise { 17 | Logger.info(`budget box deleted: ${event.budgetBox.id.uid}`); 18 | } 19 | 20 | } 21 | 22 | export default AfterBudgetBoxDeleted; 23 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/tests/__snapshots__/budget-box.aggregate.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`budget-box.aggregate should create a valid budget-box aggregate with 100% if provide not percentage 1`] = ` 4 | Object { 5 | "balanceAvailable": Object { 6 | "currency": "BRL", 7 | "value": 1000, 8 | }, 9 | "budgetPercentage": 50, 10 | "createdAt": 2022-01-01T00:00:00.000Z, 11 | "deletedAt": undefined, 12 | "description": "valid_description", 13 | "id": "valid_id", 14 | "isDeleted": false, 15 | "isPercentage": true, 16 | "ownerId": "valid_owner_id", 17 | "reasons": Array [ 18 | Object { 19 | "createdAt": 2022-01-01T00:00:00.000Z, 20 | "deletedAt": undefined, 21 | "description": "valid_description", 22 | "id": "valid_id", 23 | "isDeleted": false, 24 | "updatedAt": 2022-01-01T00:00:00.000Z, 25 | }, 26 | ], 27 | "updatedAt": 2022-01-01T00:00:00.000Z, 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/tests/budget-description.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { BudgetDescriptionValueObject } from '../budget-description.value-object'; 3 | 4 | describe('description.value-object', () => { 5 | it('should create a valid description value object', () => { 6 | const description = BudgetDescriptionValueObject.create( 7 | 'valid_description', 8 | ); 9 | expect(description.isSuccess).toBe(true); 10 | }); 11 | 12 | it('should normalize description to lowercase', () => { 13 | const description = BudgetDescriptionValueObject.create( 14 | 'VaLiD_DesCriPtiOn', 15 | ); 16 | expect(description.isSuccess).toBe(true); 17 | expect(description.getResult().value).toBe('valid_description'); 18 | }); 19 | 20 | it('should fail if not provide description', () => { 21 | const description = BudgetDescriptionValueObject.create(' '); 22 | expect(description.isFailure).toBe(true); 23 | expect(description.error).toBe( 24 | ErrorMessages.INVALID_BUDGET_DESCRIPTION_LENGTH, 25 | ); 26 | }); 27 | 28 | it('should fail if provide long description (greater than 30 char)', () => { 29 | const description = BudgetDescriptionValueObject.create( 30 | 'Invalid description length greater than max 30 char', 31 | ); 32 | expect(description.isFailure).toBe(true); 33 | expect(description.error).toBe( 34 | ErrorMessages.INVALID_BUDGET_DESCRIPTION_LENGTH, 35 | ); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/tests/mock/reason.mock.ts: -------------------------------------------------------------------------------- 1 | import { ReasonDescriptionValueObject } from "@modules/budget-box/domain/reason-description.value-object"; 2 | import { IMockEntity, IReason } from "@shared/index"; 3 | import { ReasonDomainEntity } from "@modules/budget-box/domain/reason.domain-entity"; 4 | import { DomainId, Result } from "types-ddd"; 5 | 6 | export class ReasonMock implements IMockEntity{ 7 | domain (props?: Partial): Result { 8 | 9 | const ID = DomainId.create(props?.id ?? 'valid_id'); 10 | const description = ReasonDescriptionValueObject.create(props?.description ?? 'valid_description'); 11 | 12 | if (description.isFailure) { 13 | return Result.fail(description.error); 14 | } 15 | 16 | return ReasonDomainEntity.create( 17 | { 18 | ID, 19 | description: description.getResult(), 20 | createdAt: props?.createdAt ?? new Date('2022-01-01 00:00:00'), 21 | deletedAt: undefined, 22 | isDeleted: false, 23 | updatedAt: props?.updatedAt ?? new Date('2022-01-01 00:00:00'), 24 | } 25 | ); 26 | } 27 | model (props?: Partial): IReason { 28 | return { 29 | id: props?.id ?? 'valid_id', 30 | description: props?.description ?? 'valid_description', 31 | createdAt: props?.createdAt ?? new Date('2022-01-01 00:00:00'), 32 | updatedAt: props?.updatedAt ?? new Date('2022-01-01 00:00:00'), 33 | deletedAt: undefined, 34 | isDeleted: false, 35 | }; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/tests/percentage.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { PercentageValueObject } from '../percentage.value-object'; 3 | 4 | describe('percentage.value-object', () => { 5 | it('should create a valid percentage', () => { 6 | const percentage = PercentageValueObject.create(70); 7 | expect(percentage.isSuccess).toBe(true); 8 | expect(percentage.getResult().value).toBe(70); 9 | }); 10 | 11 | it('should fail if provide a number greatter than 100', () => { 12 | const percentage = PercentageValueObject.create(170); 13 | expect(percentage.isSuccess).toBe(false); 14 | expect(percentage.error).toBe(ErrorMessages.INVALID_PERCENTAGE_VALUE); 15 | }); 16 | 17 | it('should fail if provide a number less than 0', () => { 18 | const percentage = PercentageValueObject.create(-1); 19 | expect(percentage.isSuccess).toBe(false); 20 | expect(percentage.error).toBe(ErrorMessages.INVALID_PERCENTAGE_VALUE); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/tests/reason-description.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { ReasonDescriptionValueObject } from '../reason-description.value-object'; 3 | 4 | describe('description.value-object', () => { 5 | it('should create a valid description value object', () => { 6 | const description = ReasonDescriptionValueObject.create( 7 | 'valid_description', 8 | ); 9 | expect(description.isSuccess).toBe(true); 10 | }); 11 | 12 | it('should normalize description to lowercase', () => { 13 | const description = ReasonDescriptionValueObject.create( 14 | 'VaLiD_DesCriPtiOn', 15 | ); 16 | expect(description.isSuccess).toBe(true); 17 | expect(description.getResult().value).toBe('valid_description'); 18 | }); 19 | 20 | it('should fail if not provide description', () => { 21 | const description = ReasonDescriptionValueObject.create(' '); 22 | expect(description.isFailure).toBe(true); 23 | expect(description.error).toBe( 24 | ErrorMessages.INVALID_REASON_DESCRIPTION_LENGTH, 25 | ); 26 | }); 27 | 28 | it('should fail if provide long description (greater than 30 char)', () => { 29 | const description = ReasonDescriptionValueObject.create( 30 | 'Invalid description length greater than max 30 char', 31 | ); 32 | expect(description.isFailure).toBe(true); 33 | expect(description.error).toBe( 34 | ErrorMessages.INVALID_REASON_DESCRIPTION_LENGTH, 35 | ); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/modules/budget-box/domain/tests/reason.domain-entity.spec.ts: -------------------------------------------------------------------------------- 1 | import { ReasonMock } from './mock/reason.mock'; 2 | 3 | describe('reason.domain-entity', () => { 4 | 5 | const mockReason = new ReasonMock(); 6 | 7 | it('should create a valid reason entity', () => { 8 | const reasonEntity = mockReason.domain(); 9 | 10 | expect(reasonEntity.isSuccess).toBe(true); 11 | expect(reasonEntity.getResult().isDeleted).toBe(false); 12 | expect(reasonEntity.getResult().description.value).toBe('valid_description'); 13 | }); 14 | 15 | it('should create a valid reason entity with provided id', () => { 16 | const reasonEntity = mockReason.domain({ id: 'valid_id' }); 17 | 18 | expect(reasonEntity.isSuccess).toBe(true); 19 | expect(reasonEntity.getResult().isDeleted).toBe(false); 20 | expect(reasonEntity.getResult().id.toValue()).toBe('valid_id'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/entities/budget-box.schema.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBox, ICurrency, IReason } from "@shared/index"; 2 | import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 3 | import { Document } from "mongoose"; 4 | import { DomainEvents, DomainId } from "types-ddd"; 5 | 6 | export type BudgetBoxDocument = BudgetBox & Document; 7 | 8 | @Schema({ autoCreate: true, timestamps: true, autoIndex: true }) 9 | export class BudgetBox implements IBudgetBox { 10 | 11 | @Prop({ immutable: true, required: true, type: String, index: true, unique: true }) 12 | readonly id!: string; 13 | 14 | @Prop({ required: true, type: String, index: true }) 15 | ownerId!: string; 16 | 17 | @Prop({ required: true, type: String }) 18 | description!: string; 19 | 20 | @Prop({ required: true, type: Object }) 21 | balanceAvailable!: ICurrency; 22 | 23 | @Prop({ required: true, type: Boolean }) 24 | isPercentage!: boolean; 25 | 26 | @Prop({ required: true, type: Number }) 27 | budgetPercentage!: number; 28 | 29 | @Prop({ type: [{ type: Object }], default: [], required: true }) 30 | reasons!: IReason[]; 31 | 32 | @Prop({ type: Date, required: true, default: new Date() }) 33 | createdAt!: Date; 34 | 35 | @Prop({ type: Date, required: true, default: new Date() }) 36 | updatedAt!: Date; 37 | } 38 | 39 | export const BudgetBoxSchema = SchemaFactory.createForClass(BudgetBox); 40 | 41 | // calls domain events 42 | BudgetBoxSchema.post('remove', function (doc: IBudgetBox) { 43 | const id = DomainId.create(doc.id); 44 | DomainEvents.dispatchEventsForAggregate(id.value); 45 | }); 46 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/inputs/add-reason-to-budget-box.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class AddReasonToBudgetBoxInput { 5 | 6 | @Field(() => String) 7 | reasonDescription!: string; 8 | 9 | @Field(() => String) 10 | budgetBoxId!: string; 11 | } 12 | 13 | export default AddReasonToBudgetBoxInput; 14 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/inputs/budget-box-id.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class GetBudgetBoxByIdInput { 5 | @Field(() => String) 6 | budgetBoxId!: string; 7 | } 8 | 9 | export default GetBudgetBoxByIdInput; 10 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/inputs/change-budget-box-name.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class ChangeBudgetBoxNameInput { 5 | @Field(() => String) 6 | budgetBoxId!: string; 7 | 8 | @Field(() => String) 9 | description!: string; 10 | } 11 | 12 | export default ChangeBudgetBoxNameInput; 13 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/inputs/change-budget-percentage.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, Float, InputType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class ChangeBudgetBoxPercentageInput { 5 | @Field(() => String) 6 | budgetBoxId!: string; 7 | 8 | @Field(() => Float) 9 | budgetPercentage!: number; 10 | } 11 | 12 | export default ChangeBudgetBoxPercentageInput; 13 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/inputs/change-reason-description.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class ChangeReasonDescriptionBoxInput { 5 | 6 | @Field(() => String) 7 | reasonDescription!: string; 8 | 9 | @Field(() => String) 10 | budgetBoxId!: string; 11 | 12 | @Field(() => String) 13 | reasonId!: string; 14 | } 15 | 16 | export default ChangeReasonDescriptionBoxInput; 17 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/inputs/create-budget-box.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType, Float } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class CreateBudgetBoxInput { 5 | 6 | @Field(() => String) 7 | description!: string; 8 | 9 | @Field(() => Boolean) 10 | isPercentage!: boolean; 11 | 12 | @Field(() => Float) 13 | budgetPercentage!: number; 14 | } 15 | 16 | export default CreateBudgetBoxInput; 17 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/inputs/delete-budget-box.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class DeleteBudgetBoxInput { 5 | @Field(() => String) 6 | budgetBoxId!: string; 7 | } 8 | 9 | export default DeleteBudgetBoxInput; 10 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/inputs/remove-reason-from-budget-box.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class RemoveReasonFromBudgetBoxInput { 5 | 6 | @Field(() => String) 7 | reasonId!: string; 8 | 9 | @Field(() => String) 10 | budgetBoxId!: string; 11 | } 12 | 13 | export default RemoveReasonFromBudgetBoxInput; 14 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/repo/budget-box-reason.mapper.ts: -------------------------------------------------------------------------------- 1 | import ReasonDescriptionValueObject from "@modules/budget-box/domain/reason-description.value-object"; 2 | import ReasonDomainEntity from "@modules/budget-box/domain/reason.domain-entity"; 3 | import { IReason } from "@shared/index"; 4 | import { Injectable } from "@nestjs/common"; 5 | import { DomainId, Result, TMapper } from "types-ddd"; 6 | 7 | @Injectable() 8 | export class ReasonToDomainMapper implements TMapper{ 9 | map (target: IReason): Result{ 10 | 11 | const descriptionOrError = ReasonDescriptionValueObject.create(target.description); 12 | 13 | if (descriptionOrError.isFailure) { 14 | const message = descriptionOrError.errorValue(); 15 | return Result.fail(message, 'UNPROCESSABLE_ENTITY'); 16 | } 17 | 18 | return ReasonDomainEntity.create({ 19 | ID: DomainId.create(target.id), 20 | description: descriptionOrError.getResult(), 21 | createdAt: target.createdAt, 22 | updatedAt: target.updatedAt, 23 | isDeleted: target.isDeleted, 24 | deletedAt: target.deletedAt 25 | }); 26 | }; 27 | } 28 | 29 | export default ReasonToDomainMapper; 30 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/repo/tests/budget-box-mapper.spec.ts: -------------------------------------------------------------------------------- 1 | import BudgetBoxAggregate from "@modules/budget-box/domain/budget-box.aggregate"; 2 | import { BudgetBoxMock } from "@modules/budget-box/domain/tests/mock/budget-box.mock"; 3 | import { IBudgetBox, IMockEntity } from "@shared/index"; 4 | import { TMapper } from "types-ddd"; 5 | import ReasonToDomainMapper from "../budget-box-reason.mapper"; 6 | import BudgetBoxToDomainMapper from "../budget-box.mapper"; 7 | 8 | describe('budget-box.mapper', () => { 9 | 10 | 11 | let mapper: TMapper; 12 | let mock: IMockEntity; 13 | 14 | beforeAll(() => { 15 | mapper = new BudgetBoxToDomainMapper(new ReasonToDomainMapper()); 16 | mock = new BudgetBoxMock(); 17 | }); 18 | 19 | it('should return a fail if provide an invalid percentage value', () => { 20 | const invalidPercentage = 110; // 0 - 100 21 | 22 | const model = mock.model({ budgetPercentage: invalidPercentage }); 23 | 24 | const result = mapper.map(model); 25 | 26 | expect(result.isFailure).toBeTruthy(); 27 | expect(result.statusCodeNumber).toBe(422); 28 | }); 29 | 30 | it('should return a valid aggregate', () => { 31 | 32 | const model = mock.model(); 33 | const aggregate = mock.domain(model); 34 | 35 | const result = mapper.map(model); 36 | 37 | expect(result.isSuccess).toBeTruthy(); 38 | expect(result.statusCodeNumber).toBe(200); 39 | expect(result.getResult()).toEqual(aggregate.getResult()); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/services/queries/budget-box-query.interface.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBox } from "@modules/shared"; 2 | export interface IBudgetBoxQueryService { 3 | getBudgetBoxesByOwnerId(ownerId: string): Promise; 4 | getBudgetBoxByIdAndOwnerId(filter: { ownerId: string, id: string }): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/services/queries/budget-box-query.service.ts: -------------------------------------------------------------------------------- 1 | import { BudgetBox, BudgetBoxDocument } from "@modules/budget-box/infra/entities/budget-box.schema"; 2 | import { IBudgetBoxQueryService } from "./budget-box-query.interface"; 3 | import { Injectable } from "@nestjs/common"; 4 | import { InjectModel } from "@nestjs/mongoose"; 5 | import { Model } from "mongoose"; 6 | import { IBudgetBox } from "@modules/shared"; 7 | 8 | @Injectable() 9 | export class BudgetBoxQueryService implements IBudgetBoxQueryService { 10 | 11 | constructor ( 12 | @InjectModel(BudgetBox.name) private readonly conn: Model, 13 | ) { } 14 | async getBudgetBoxByIdAndOwnerId (filter: { ownerId: string; id: string; }): Promise { 15 | const budgetBoxesFound = await this.conn.findOne({ ...filter }, { _id: false, __v: false }); 16 | 17 | return budgetBoxesFound; 18 | } 19 | async getBudgetBoxesByOwnerId (ownerId: string): Promise { 20 | const budgetBoxesFound = await this.conn.find({ ownerId }, { _id: false, __v: false }); 21 | 22 | return budgetBoxesFound; 23 | } 24 | 25 | } 26 | 27 | export default BudgetBoxQueryService; 28 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/tests/budget-box.mutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "graphql-request"; 2 | 3 | export const RESULT = gql``; 4 | 5 | export const CREATE_BUDGET_BOX_MUTATION = gql` 6 | mutation($CreateBudgetBoxInput: CreateBudgetBoxInput!) { 7 | createBudgetBox(CreateBudgetBoxInput: $CreateBudgetBoxInput) 8 | }${RESULT} 9 | `; 10 | 11 | export const ADD_REASON_TO_BUDGET_BOX_MUTATION = gql` 12 | mutation($AddReasonToBudgetBoxInput: AddReasonToBudgetBoxInput!) { 13 | addReasonToBudgetBox(AddReasonToBudgetBoxInput: $AddReasonToBudgetBoxInput) 14 | }${RESULT} 15 | `; 16 | 17 | export const CHANGE_BUDGET_BOX_REASON_DESCRIPTION_MUTATION = gql` 18 | mutation($ChangeReasonDescriptionBoxInput: ChangeReasonDescriptionBoxInput!) { 19 | changeReasonDescription(ChangeReasonDescriptionBoxInput: $ChangeReasonDescriptionBoxInput) 20 | }${RESULT} 21 | `; 22 | 23 | export const REMOVE_REASON_FROM_BUDGET_BOX_MUTATION = gql` 24 | mutation($RemoveReasonFromBudgetBoxInput: RemoveReasonFromBudgetBoxInput!) { 25 | removeReasonFromBudgetBox(RemoveReasonFromBudgetBoxInput: $RemoveReasonFromBudgetBoxInput) 26 | }${RESULT} 27 | `; 28 | 29 | export const CHANGE_BUDGET_BOX_NAME_MUTATION = gql` 30 | mutation($ChangeBudgetBoxNameInput: ChangeBudgetBoxNameInput!) { 31 | changeBudgetName(ChangeBudgetBoxNameInput: $ChangeBudgetBoxNameInput) 32 | }${RESULT} 33 | `; 34 | 35 | export const CHANGE_BUDGET_BOX_PERCENTAGE_MUTATION = gql` 36 | mutation($ChangeBudgetBoxPercentageInput: ChangeBudgetBoxPercentageInput!) { 37 | changeBudgetPercentage(ChangeBudgetBoxPercentageInput: $ChangeBudgetBoxPercentageInput) 38 | }${RESULT} 39 | `; 40 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/tests/budget-box.query.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "graphql-request"; 2 | 3 | export const RESULT = gql``; 4 | 5 | export const BUDGET_BOX_FRAGMENT = gql` 6 | fragment BudgetBox on BudgetBoxType { 7 | id 8 | balanceAvailable { 9 | value 10 | } 11 | budgetPercentage 12 | description 13 | reasons { 14 | id 15 | description 16 | } 17 | isPercentage 18 | } 19 | `; 20 | 21 | export const GET_BUDGET_BOXES = gql` 22 | query { 23 | getBudgetBoxes { 24 | ...BudgetBox 25 | } 26 | }${BUDGET_BOX_FRAGMENT} 27 | `; 28 | 29 | export const GET_BUDGET_BOX_BY_ID = gql` 30 | query($GetBudgetBoxByIdInput: GetBudgetBoxByIdInput!) { 31 | getBudgetBoxById(GetBudgetBoxByIdInput: $GetBudgetBoxByIdInput){ 32 | ...BudgetBox 33 | } 34 | }${BUDGET_BOX_FRAGMENT} 35 | `; 36 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/types/budget-box.type.ts: -------------------------------------------------------------------------------- 1 | import { ICurrency } from "@modules/shared"; 2 | import { Field, Float, ID, ObjectType, registerEnumType } from "@nestjs/graphql"; 3 | import ReasonType from "./reason.type"; 4 | 5 | enum BudgetBoxCurrencies { 6 | 'BRL' = 'BRL', 7 | 'USD' = 'USD', 8 | 'EUR' = 'EUR', 9 | 'JPY' = 'JPY' 10 | } 11 | 12 | type currency = keyof typeof BudgetBoxCurrencies; 13 | 14 | registerEnumType(BudgetBoxCurrencies, { 15 | name: 'BudgetBoxCurrencies', 16 | }); 17 | 18 | @ObjectType() 19 | export class BudgetBoxCurrencyType { 20 | @Field(() => Float) 21 | value!: number; 22 | 23 | @Field(() => BudgetBoxCurrencies) 24 | currency!: currency; 25 | } 26 | 27 | @ObjectType() 28 | export class BudgetBoxType { 29 | 30 | @Field(() => ID) 31 | id!: string; 32 | 33 | @Field(() => String) 34 | description!: string; 35 | 36 | @Field(() => BudgetBoxCurrencyType) 37 | balanceAvailable!: ICurrency; 38 | 39 | @Field(() => Boolean) 40 | isPercentage!: boolean; 41 | 42 | @Field(() => Number) 43 | budgetPercentage!: number; 44 | 45 | @Field(() => [ReasonType], { defaultValue: []}) 46 | reasons!: Array; 47 | 48 | @Field(() => Date) 49 | createdAt!: Date; 50 | 51 | @Field(() => Date) 52 | updatedAt!: Date; 53 | 54 | } 55 | 56 | export default BudgetBoxType; 57 | -------------------------------------------------------------------------------- /src/modules/budget-box/infra/types/reason.type.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from "@nestjs/graphql"; 2 | 3 | @ObjectType() 4 | export class ReasonType { 5 | 6 | @Field(() => ID) 7 | id!: string; 8 | 9 | @Field(() => String) 10 | description!: string; 11 | 12 | @Field(() => Date) 13 | createdAt!: Date; 14 | 15 | @Field(() => Date) 16 | updatedAt!: Date; 17 | 18 | } 19 | 20 | export default ReasonType; 21 | -------------------------------------------------------------------------------- /src/modules/shared/domain/budget-box-connection.interface.ts: -------------------------------------------------------------------------------- 1 | import IBudgetBox from "../interfaces/budget-box-model.interface"; 2 | 3 | 4 | export interface IFilter { 5 | ownerId: string; 6 | id: string; 7 | } 8 | 9 | export interface IBudgetBoxConnection { 10 | findBudgetBoxesByUserId(userId: string): Promise; 11 | findBudgetBoxByIdAndUserId(filter: IFilter): Promise; 12 | updateBudgetBoxesBalance(data: IBudgetBox[]): Promise; 13 | getBudgetBoxesByIds(ids: string[]): Promise; 14 | deleteBudgetBoxByUserId(userId: string): Promise; 15 | } 16 | 17 | export default IBudgetBoxConnection; 18 | -------------------------------------------------------------------------------- /src/modules/shared/domain/can-create-transaction.domain-service.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from "@nestjs/common"; 2 | import { Result } from "types-ddd"; 3 | import { IDomainService } from "@modules/shared/interfaces/domain-service.interface"; 4 | import { IBudgetBoxConnection } from "./budget-box-connection.interface"; 5 | 6 | interface Dto { 7 | userId: string; 8 | } 9 | 10 | export class CanCreateTransactionDomainService implements IDomainService>{ 11 | constructor ( 12 | @Inject('BudgetBoxConnection') 13 | private readonly connection: IBudgetBoxConnection 14 | ) { } 15 | async execute ({ userId: ownerId }: Dto): Promise> { 16 | try { 17 | 18 | const maxPercentage = 100; 19 | const initialValue = 0; 20 | 21 | const budgetBoxes = await this.connection.findBudgetBoxesByUserId(ownerId); 22 | 23 | const totalPercentageAllocated = budgetBoxes.reduce((total, budgetBox) => { 24 | if (budgetBox.isPercentage) { 25 | return total + budgetBox.budgetPercentage; 26 | } 27 | return total; 28 | }, initialValue); 29 | 30 | const canCreateTransaction = totalPercentageAllocated === maxPercentage; 31 | 32 | if (canCreateTransaction) { 33 | return Result.ok(canCreateTransaction); 34 | } 35 | 36 | const available = maxPercentage - totalPercentageAllocated; 37 | return Result.fail(`You must allocate 100% on budget boxes. ${available}% not allocated`); 38 | 39 | } catch (error) { 40 | return Result.fail(`Internal Server Error On CanCreateTransaction Proxy`, 'INTERNAL_SERVER_ERROR'); 41 | } 42 | } 43 | } 44 | 45 | export default CanCreateTransactionDomainService; 46 | -------------------------------------------------------------------------------- /src/modules/shared/domain/delete-budget-box-by-user-id.domain-service.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from "@nestjs/common"; 2 | import { Result } from "types-ddd"; 3 | import { IDomainService } from "@modules/shared/interfaces/domain-service.interface"; 4 | import { IBudgetBoxConnection } from "./budget-box-connection.interface"; 5 | 6 | interface Dto { 7 | userId: string; 8 | } 9 | 10 | export class DeleteBudgetBoxByUserIdDomainService implements IDomainService>{ 11 | constructor ( 12 | @Inject('BudgetBoxConnection') 13 | private readonly connection: IBudgetBoxConnection 14 | ) { } 15 | async execute ({ userId }: Dto): Promise> { 16 | try { 17 | 18 | const isSuccess = await this.connection.deleteBudgetBoxByUserId(userId); 19 | 20 | if (!isSuccess) { 21 | return Result.fail('Could not delete budget boxes for user', 'GATEWAY_TIMEOUT'); 22 | } 23 | 24 | return Result.success(); 25 | 26 | } catch (error) { 27 | return Result.fail( 28 | `Internal Server Error On Delete Budget Box By UserId Domain Service`, 'INTERNAL_SERVER_ERROR' 29 | ); 30 | } 31 | } 32 | } 33 | 34 | export default DeleteBudgetBoxByUserIdDomainService; 35 | -------------------------------------------------------------------------------- /src/modules/shared/domain/delete-transactions-by-user-id.domain-service.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from "@nestjs/common"; 2 | import { Result } from "types-ddd"; 3 | import { IDomainService } from "@modules/shared/interfaces/domain-service.interface"; 4 | import ITransactionConnection from "./transaction-connection.interface"; 5 | 6 | interface Dto { 7 | userId: string; 8 | } 9 | 10 | export class DeleteTransactionsByUserIdDomainService implements IDomainService>{ 11 | constructor ( 12 | @Inject('TransactionConnection') 13 | private readonly connection: ITransactionConnection 14 | ) { } 15 | async execute ({ userId }: Dto): Promise> { 16 | try { 17 | 18 | const isSuccess = await this.connection.deleteTransactionByUserId(userId); 19 | 20 | if (!isSuccess) { 21 | return Result.fail('Could not delete transactions for user', 'GATEWAY_TIMEOUT'); 22 | } 23 | 24 | return Result.success(); 25 | 26 | } catch (error) { 27 | return Result.fail( 28 | `Internal Server Error On Delete Transactions By UserId Domain Service`, 'INTERNAL_SERVER_ERROR' 29 | ); 30 | } 31 | } 32 | } 33 | 34 | export default DeleteTransactionsByUserIdDomainService; 35 | -------------------------------------------------------------------------------- /src/modules/shared/domain/tests/delete-budget-box-by-user-id-domain-service.spec.ts: -------------------------------------------------------------------------------- 1 | import IBudgetBoxConnection from "../budget-box-connection.interface"; 2 | import DeleteBudgetBoxByUserIdDomainService from "../delete-budget-box-by-user-id.domain-service"; 3 | import budgetBoxConnectionMock from "./mocks/budget-box-connection.mock"; 4 | 5 | describe('delete-budget-box-by-user-id.domain-service', () => { 6 | 7 | let fakeConnection: IBudgetBoxConnection; 8 | 9 | beforeEach(() => { 10 | fakeConnection = budgetBoxConnectionMock; 11 | }); 12 | 13 | it('should delete budget boxes with success', async () => { 14 | 15 | jest.spyOn(fakeConnection, 'deleteBudgetBoxByUserId').mockResolvedValueOnce(true); 16 | 17 | const service = new DeleteBudgetBoxByUserIdDomainService(fakeConnection); 18 | 19 | const result = await service.execute({ userId: 'valid_id' }); 20 | 21 | expect(result.isSuccess).toBeTruthy(); 22 | }); 23 | 24 | it('should returns fails if does not delete', async () => { 25 | 26 | jest.spyOn(fakeConnection, 'deleteBudgetBoxByUserId').mockResolvedValueOnce(false); 27 | 28 | const service = new DeleteBudgetBoxByUserIdDomainService(fakeConnection); 29 | 30 | const result = await service.execute({ userId: 'valid_id' }); 31 | 32 | expect(result.isFailure).toBeTruthy(); 33 | expect(result.error).toBe('Could not delete budget boxes for user'); 34 | }); 35 | 36 | it('should returns fails if connection throws', async () => { 37 | 38 | jest.spyOn(fakeConnection, 'deleteBudgetBoxByUserId').mockImplementationOnce(async () => { 39 | throw new Error("error"); 40 | }); 41 | 42 | const service = new DeleteBudgetBoxByUserIdDomainService(fakeConnection); 43 | 44 | const result = await service.execute({ userId: 'valid_id' }); 45 | 46 | expect(result.isFailure).toBeTruthy(); 47 | expect(result.error).toBe('Internal Server Error On Delete Budget Box By UserId Domain Service'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/modules/shared/domain/tests/delete-transactions-by-user-id-domain-service.spec.ts: -------------------------------------------------------------------------------- 1 | import ITransactionConnection from "../transaction-connection.interface"; 2 | import DeleteTransactionUserIdDomainService from "../delete-transactions-by-user-id.domain-service"; 3 | import transactionConnectionMock from "./mocks/transaction-connection.mock"; 4 | 5 | describe('delete-transactions-by-user-id.domain-service', () => { 6 | 7 | let fakeConnection: ITransactionConnection; 8 | 9 | beforeEach(() => { 10 | fakeConnection = transactionConnectionMock; 11 | }); 12 | 13 | it('should delete transactions with success', async () => { 14 | 15 | jest.spyOn(fakeConnection, 'deleteTransactionByUserId').mockResolvedValueOnce(true); 16 | 17 | const service = new DeleteTransactionUserIdDomainService(fakeConnection); 18 | 19 | const result = await service.execute({ userId: 'valid_id' }); 20 | 21 | expect(result.isSuccess).toBeTruthy(); 22 | }); 23 | 24 | it('should returns fails if does not delete', async () => { 25 | 26 | jest.spyOn(fakeConnection, 'deleteTransactionByUserId').mockResolvedValueOnce(false); 27 | 28 | const service = new DeleteTransactionUserIdDomainService(fakeConnection); 29 | 30 | const result = await service.execute({ userId: 'valid_id' }); 31 | 32 | expect(result.isFailure).toBeTruthy(); 33 | expect(result.error).toBe('Could not delete transactions for user'); 34 | }); 35 | 36 | it('should returns fails if connection throws', async () => { 37 | 38 | jest.spyOn(fakeConnection, 'deleteTransactionByUserId').mockImplementationOnce(async () => { 39 | throw new Error("error"); 40 | }); 41 | 42 | const service = new DeleteTransactionUserIdDomainService(fakeConnection); 43 | 44 | const result = await service.execute({ userId: 'valid_id' }); 45 | 46 | expect(result.isFailure).toBeTruthy(); 47 | expect(result.error).toBe('Internal Server Error On Delete Transactions By UserId Domain Service'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/modules/shared/domain/tests/mocks/budget-box-connection.mock.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxConnection } from "../../budget-box-connection.interface"; 2 | 3 | export const budgetBoxConnectionMock: IBudgetBoxConnection = { 4 | findBudgetBoxesByUserId: jest.fn(), 5 | findBudgetBoxByIdAndUserId: jest.fn(), 6 | getBudgetBoxesByIds: jest.fn(), 7 | updateBudgetBoxesBalance: jest.fn(), 8 | deleteBudgetBoxByUserId: jest.fn() 9 | }; 10 | 11 | export default budgetBoxConnectionMock; 12 | -------------------------------------------------------------------------------- /src/modules/shared/domain/tests/mocks/transaction-connection.mock.ts: -------------------------------------------------------------------------------- 1 | import ITransactionConnection from "../../transaction-connection.interface"; 2 | 3 | export const transactionConnectionMock: ITransactionConnection = { 4 | deleteTransactionByUserId: jest.fn() 5 | }; 6 | 7 | export default transactionConnectionMock; 8 | -------------------------------------------------------------------------------- /src/modules/shared/domain/transaction-connection.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ITransactionConnection { 2 | deleteTransactionByUserId(userId: string): Promise; 3 | } 4 | 5 | export default ITransactionConnection; 6 | -------------------------------------------------------------------------------- /src/modules/shared/domain/update-budget-box-balance.domain-service.ts: -------------------------------------------------------------------------------- 1 | import { CurrencyValueObject, DomainId, Result } from "types-ddd"; 2 | import IDomainService from "@shared/interfaces/domain-service.interface"; 3 | import { Inject } from "@nestjs/common"; 4 | import { IBudgetBoxConnection } from "./budget-box-connection.interface"; 5 | import CalculateValueToUpdate from "../utils/calculate"; 6 | 7 | export interface IBoxes { 8 | budgetBoxId: DomainId; 9 | value: CurrencyValueObject; 10 | } 11 | 12 | export type OperationType = 'SUM' | 'SUBTRACT' | 'NONE'; 13 | 14 | export interface UpdateBudgetBoxBalanceDto { 15 | budgetBoxes: IBoxes[]; 16 | operationType: OperationType; 17 | } 18 | 19 | export class UpdateBudgetBoxBalanceDomainService implements IDomainService>{ 20 | constructor ( 21 | @Inject('BudgetBoxConnection') 22 | private readonly connection: IBudgetBoxConnection, 23 | 24 | @Inject(CalculateValueToUpdate) 25 | private readonly calculator: CalculateValueToUpdate 26 | ){} 27 | async execute ({ budgetBoxes, operationType }: UpdateBudgetBoxBalanceDto): Promise> { 28 | try { 29 | 30 | if (operationType === 'NONE') return Result.success(); 31 | 32 | const ids = budgetBoxes.map((box) => box.budgetBoxId.uid); 33 | 34 | const budgetBoxFromDataBase = await this.connection.getBudgetBoxesByIds(ids); 35 | 36 | const documentsToUpdate = this.calculator.calc({ 37 | budgetBoxFromDataBase, 38 | operationType, 39 | budgetBoxesDto: budgetBoxes 40 | }); 41 | 42 | const isSuccess = await this.connection.updateBudgetBoxesBalance(documentsToUpdate); 43 | 44 | if (isSuccess) { 45 | return Result.success(); 46 | } 47 | 48 | return Result.fail('Error On Update Budget Box Balance Domain Service', 'SERVICE_UNAVAILABLE'); 49 | 50 | } catch (error: any) { 51 | return Result.fail(`Internal Server Error: ${error.message}`, 'INTERNAL_SERVER_ERROR'); 52 | } 53 | } 54 | } 55 | 56 | export default UpdateBudgetBoxBalanceDomainService; 57 | -------------------------------------------------------------------------------- /src/modules/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils/error-messages/messages'; 2 | export * from './interfaces/budget-box-model.interface'; 3 | export * from './interfaces/entity-mock.interface'; 4 | export * from './interfaces/reason-model.interface'; 5 | export * from './interfaces/transaction-model.interface'; 6 | export * from './interfaces/user-model.interface'; 7 | export * from './interfaces/domain-service.interface'; 8 | export * from './domain/budget-box-connection.interface'; 9 | export * from './infra/connections/budget-box-connection'; 10 | export * from './infra/shared.module'; 11 | export * from './proxies/base.proxy'; 12 | export * from './infra/connections/connection'; -------------------------------------------------------------------------------- /src/modules/shared/infra/connections/base-connection.interface.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from "mongodb"; 2 | 3 | export interface IBaseConnection { 4 | instance(): Promise; 5 | } 6 | 7 | export default IBaseConnection; 8 | -------------------------------------------------------------------------------- /src/modules/shared/infra/connections/budget-box-connection.ts: -------------------------------------------------------------------------------- 1 | import { BUDGET_BOX_COLLECTION_NAME, DB_NAME } from "@config/env"; 2 | import { IBudgetBoxConnection, IFilter } from "@modules/shared/domain/budget-box-connection.interface"; 3 | import IBudgetBox from "@modules/shared/interfaces/budget-box-model.interface"; 4 | import {MongoClient} from 'mongodb'; 5 | 6 | export class BudgetBoxConnection implements IBudgetBoxConnection { 7 | constructor ( 8 | private readonly connection: MongoClient 9 | ) { } 10 | 11 | async updateBudgetBoxesBalance (data: IBudgetBox[]): Promise { 12 | 13 | const MakeCommand = (model: IBudgetBox) => ({ 14 | updateOne: { 15 | filter: { id: model.id }, 16 | update: { $set: model }, 17 | upsert: true 18 | } 19 | }); 20 | 21 | const command = data.map((doc) => MakeCommand(doc)); 22 | 23 | const payload = await this.connection.db(DB_NAME) 24 | .collection(BUDGET_BOX_COLLECTION_NAME).bulkWrite(command); 25 | 26 | return payload.isOk(); 27 | } 28 | 29 | async getBudgetBoxesByIds (ids: string[]): Promise { 30 | const result = await this.connection.db(DB_NAME) 31 | .collection(BUDGET_BOX_COLLECTION_NAME).find({ id: { $in: ids } }).toArray(); 32 | 33 | return result; 34 | } 35 | 36 | async findBudgetBoxByIdAndUserId ({ id, ownerId }: IFilter): Promise { 37 | const budgetBox = await this.connection.db(DB_NAME) 38 | .collection(BUDGET_BOX_COLLECTION_NAME).findOne({ id, ownerId }); 39 | 40 | if (!budgetBox) { 41 | return null; 42 | } 43 | 44 | return budgetBox; 45 | } 46 | 47 | async findBudgetBoxesByUserId (ownerId: string): Promise { 48 | 49 | const result = await this.connection.db(DB_NAME) 50 | .collection(BUDGET_BOX_COLLECTION_NAME).find({ ownerId }).toArray(); 51 | 52 | return result; 53 | } 54 | 55 | async deleteBudgetBoxByUserId (ownerId: string): Promise { 56 | const result = await this.connection.db(DB_NAME) 57 | .collection(BUDGET_BOX_COLLECTION_NAME).deleteMany({ ownerId }); 58 | 59 | return !!result.acknowledged; 60 | } 61 | } 62 | 63 | export default BudgetBoxConnection; 64 | -------------------------------------------------------------------------------- /src/modules/shared/infra/connections/connection.ts: -------------------------------------------------------------------------------- 1 | import IBaseConnection from "./base-connection.interface"; 2 | import { MongoURI } from "@config/mongo.config"; 3 | import { MongoClient } from "mongodb"; 4 | 5 | export class BaseConnection implements IBaseConnection { 6 | 7 | protected conn: MongoClient | null = null; 8 | 9 | async instance (): Promise { 10 | if (!this.conn) { 11 | this.conn = new MongoClient(MongoURI); 12 | await this.conn.connect(); 13 | } 14 | return this.conn; 15 | }; 16 | } 17 | 18 | export default BaseConnection; 19 | -------------------------------------------------------------------------------- /src/modules/shared/infra/connections/transaction-connection.ts: -------------------------------------------------------------------------------- 1 | import { DB_NAME, TRANSACTION_COLLECTION_NAME } from "@config/env"; 2 | import ITransactionConnection from "@modules/shared/domain/transaction-connection.interface"; 3 | import {MongoClient} from 'mongodb'; 4 | 5 | export class TransactionConnection implements ITransactionConnection { 6 | constructor ( 7 | private readonly connection: MongoClient 8 | ) { } 9 | 10 | async deleteTransactionByUserId (userId: string): Promise { 11 | const result = await this.connection.db(DB_NAME) 12 | .collection(TRANSACTION_COLLECTION_NAME).deleteMany({ userId }); 13 | 14 | return !!result.acknowledged; 15 | } 16 | 17 | } 18 | 19 | export default TransactionConnection; 20 | -------------------------------------------------------------------------------- /src/modules/shared/infra/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { MongoClient } from "mongodb"; 3 | import CanCreateTransactionDomainService from "@shared/domain/can-create-transaction.domain-service"; 4 | import UpdateBudgetBoxBalanceDomainService from "@shared/domain/update-budget-box-balance.domain-service"; 5 | import BudgetBoxConnection from "./connections/budget-box-connection"; 6 | import { BaseConnection } from "./connections/connection"; 7 | import DeleteTransactionsByUserIdDomainService from "../domain/delete-transactions-by-user-id.domain-service"; 8 | import DeleteBudgetBoxByUserIdDomainService from "../domain/delete-budget-box-by-user-id.domain-service"; 9 | import TransactionConnection from "./connections/transaction-connection"; 10 | import CalculateValueToUpdate from "../utils/calculate"; 11 | 12 | @Module({ 13 | imports: [], 14 | providers: [ 15 | { 16 | provide: BaseConnection, 17 | useFactory: async () => { 18 | const client = new BaseConnection(); 19 | const conn = await client.instance(); 20 | return conn; 21 | } 22 | }, 23 | { 24 | provide: 'BudgetBoxConnection', 25 | useFactory: (conn: MongoClient)=> new BudgetBoxConnection(conn), 26 | inject:[BaseConnection] 27 | }, 28 | { 29 | provide: 'TransactionConnection', 30 | useFactory: (conn: MongoClient)=> new TransactionConnection(conn), 31 | inject:[BaseConnection] 32 | }, 33 | { 34 | provide: 'UpdateBudgetBoxBalanceDomainService', 35 | useClass: UpdateBudgetBoxBalanceDomainService 36 | }, 37 | CanCreateTransactionDomainService, 38 | UpdateBudgetBoxBalanceDomainService, 39 | DeleteTransactionsByUserIdDomainService, 40 | DeleteBudgetBoxByUserIdDomainService, 41 | CalculateValueToUpdate 42 | ], 43 | exports: [ 44 | BaseConnection, 45 | 'BudgetBoxConnection', 46 | 'TransactionConnection', 47 | 'UpdateBudgetBoxBalanceDomainService', 48 | CanCreateTransactionDomainService, 49 | UpdateBudgetBoxBalanceDomainService, 50 | DeleteTransactionsByUserIdDomainService, 51 | DeleteBudgetBoxByUserIdDomainService, 52 | CalculateValueToUpdate 53 | ] 54 | }) 55 | export class SharedModule { } 56 | -------------------------------------------------------------------------------- /src/modules/shared/interfaces/budget-box-model.interface.ts: -------------------------------------------------------------------------------- 1 | import IReason from "./reason-model.interface"; 2 | import { ICurrency } from "./transaction-model.interface"; 3 | 4 | export interface IBudgetBox { 5 | 6 | readonly id: string; 7 | 8 | ownerId: string; 9 | 10 | description: string; 11 | 12 | balanceAvailable: ICurrency; 13 | 14 | isPercentage: boolean; 15 | 16 | budgetPercentage: number; 17 | 18 | reasons: IReason[]; 19 | 20 | createdAt: Date; 21 | 22 | updatedAt: Date; 23 | 24 | isDeleted?: boolean; 25 | 26 | deletedAt?: Date; 27 | } 28 | 29 | export default IBudgetBox; 30 | -------------------------------------------------------------------------------- /src/modules/shared/interfaces/domain-service.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IDomainService { 2 | execute(data: T): Promise; 3 | } 4 | 5 | export default IDomainService; 6 | -------------------------------------------------------------------------------- /src/modules/shared/interfaces/entity-mock.interface.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "types-ddd"; 2 | 3 | export interface IMockEntity { 4 | domain(props?: Partial): Result; 5 | model(props?: Partial): Model; 6 | } 7 | 8 | export default IMockEntity; 9 | -------------------------------------------------------------------------------- /src/modules/shared/interfaces/reason-model.interface.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface IReason { 3 | 4 | readonly id: string; 5 | 6 | description: string; 7 | 8 | createdAt: Date; 9 | 10 | updatedAt: Date; 11 | 12 | isDeleted?: boolean; 13 | 14 | deletedAt?: Date; 15 | } 16 | 17 | export default IReason; 18 | -------------------------------------------------------------------------------- /src/modules/shared/interfaces/transaction-model.interface.ts: -------------------------------------------------------------------------------- 1 | import { transactionStatus } from "@modules/transaction/domain/transaction-status.value-object"; 2 | import { transactionType } from "@modules/transaction/domain/transaction-type.value-object"; 3 | 4 | export interface ICurrency { 5 | readonly value: number; 6 | readonly currency: 'BRL' | 'USD' | 'EUR' | 'JPY' 7 | } 8 | 9 | export interface ICalculation { 10 | budgetBoxName: string; 11 | budgetBoxId: string; 12 | readonly currency: ICurrency; 13 | } 14 | 15 | export interface ITransaction { 16 | 17 | readonly id: string; 18 | 19 | readonly userId: string; 20 | 21 | readonly reason: string; 22 | 23 | readonly paymentDate: Date; 24 | 25 | readonly transactionType: transactionType; 26 | 27 | status: transactionStatus; 28 | 29 | readonly transactionCalculations: readonly ICalculation[]; 30 | 31 | note: string | null; 32 | 33 | attachment: string | null; 34 | 35 | readonly createdAt: Date; 36 | 37 | updatedAt: Date; 38 | 39 | isDeleted?: boolean; 40 | 41 | deletedAt?: Date; 42 | 43 | readonly totalValue?: ICurrency; 44 | } 45 | 46 | export default ITransaction; 47 | -------------------------------------------------------------------------------- /src/modules/shared/interfaces/user-model.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IUserAgent { 2 | name: string; 3 | version: string; 4 | os: string; 5 | type: string; 6 | } 7 | 8 | export interface ITerm { 9 | ip: string; 10 | acceptedAt: Date; 11 | userAgent: IUserAgent; 12 | isAccepted: boolean; 13 | } 14 | 15 | export interface IUser { 16 | readonly id: string; 17 | 18 | email: string; 19 | 20 | password: string; 21 | 22 | terms: Array; 23 | 24 | createdAt: Date; 25 | 26 | updatedAt: Date; 27 | 28 | isDeleted?: boolean; 29 | } 30 | 31 | export default IUser; 32 | -------------------------------------------------------------------------------- /src/modules/shared/proxies/base.proxy.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase, Result, TSProxy } from "types-ddd"; 2 | 3 | export class BaseProxy extends TSProxy { 4 | constructor ( 5 | canExecute: IUseCase>, 6 | useCase: IUseCase>, 7 | ) { 8 | super({ 9 | execute: useCase, 10 | canExecute: canExecute, 11 | }); 12 | } 13 | }; 14 | 15 | export default BaseProxy; 16 | -------------------------------------------------------------------------------- /src/modules/shared/proxies/tests/base-proxy.spec.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase, Result } from "types-ddd"; 2 | import BaseProxy from "../base.proxy"; 3 | 4 | describe('base.proxy', () => { 5 | 6 | const useCaseToExecute: IUseCase> = { execute: jest.fn() }; 7 | const canExecute: IUseCase> = { execute: jest.fn() }; 8 | 9 | it('should cannot execute if canExecute function returns fail', async () => { 10 | 11 | jest.spyOn(canExecute, 'execute').mockResolvedValueOnce(Result.fail('cannot execute')); 12 | const useCase = jest.spyOn(useCaseToExecute, 'execute'); 13 | 14 | const proxy = new BaseProxy(canExecute, useCaseToExecute); 15 | 16 | const result = await proxy.execute('invalid_email@domain.com'); 17 | 18 | expect(result.isFailure).toBeTruthy(); 19 | expect(useCase).not.toBeCalled(); 20 | }); 21 | 22 | it('should cannot execute if canExecute function returns false', async () => { 23 | 24 | jest.spyOn(canExecute, 'execute').mockResolvedValueOnce(Result.ok(false)); 25 | const useCase = jest.spyOn(useCaseToExecute, 'execute'); 26 | 27 | const proxy = new BaseProxy(canExecute, useCaseToExecute); 28 | 29 | const result = await proxy.execute('invalid_email@domain.com'); 30 | 31 | expect(result.isFailure).toBeTruthy(); 32 | expect(useCase).not.toBeCalled(); 33 | }); 34 | 35 | it('should can execute with success if canExecute function returns true', async () => { 36 | 37 | jest.spyOn(canExecute, 'execute').mockResolvedValueOnce(Result.ok(true)); 38 | jest.spyOn(useCaseToExecute, 'execute').mockResolvedValueOnce(Result.ok('valid_email@domain.com')); 39 | 40 | const proxy = new BaseProxy(canExecute, useCaseToExecute); 41 | 42 | const result = await proxy.execute('valid_email@domain.com'); 43 | 44 | expect(result.isSuccess).toBeTruthy(); 45 | }); 46 | 47 | }); -------------------------------------------------------------------------------- /src/modules/shared/utils/calculate.ts: -------------------------------------------------------------------------------- 1 | import { CURRENCY } from "@config/env"; 2 | import { CurrencyValueObject } from "types-ddd"; 3 | import { IBoxes, OperationType } from "../domain/update-budget-box-balance.domain-service"; 4 | import IBudgetBox from "../interfaces/budget-box-model.interface"; 5 | 6 | export interface ICalculate { 7 | budgetBoxFromDataBase: IBudgetBox[]; 8 | budgetBoxesDto: IBoxes[]; 9 | operationType: OperationType; 10 | } 11 | 12 | export class CalculateValueToUpdate { 13 | calc (props: ICalculate): IBudgetBox[] { 14 | 15 | const { operationType, budgetBoxFromDataBase, budgetBoxesDto } = props; 16 | 17 | const calculateValueToApply = (currentDocumentValue: number, eventValue: CurrencyValueObject): number => { 18 | const currency = CurrencyValueObject 19 | .create({ value: currentDocumentValue, currency: CURRENCY }) 20 | .getResult(); 21 | 22 | if (operationType === 'SUM') { 23 | return currency.add(eventValue.value).getResult().value; 24 | 25 | } else if (operationType === 'SUBTRACT') { 26 | return currency.subtractBy(eventValue.value).getResult().value; 27 | 28 | } 29 | return currentDocumentValue; 30 | }; 31 | 32 | const documentToUpdate = budgetBoxFromDataBase.map((model): IBudgetBox => { 33 | const eventData = budgetBoxesDto.find((box) => box.budgetBoxId.uid === model.id); 34 | if (!eventData) { 35 | return model; 36 | } 37 | const totalToApply = calculateValueToApply(model.balanceAvailable.value, eventData.value); 38 | const balanceAvailable = { ...model.balanceAvailable, value: totalToApply }; 39 | return Object.assign({}, model, { balanceAvailable }); 40 | }); 41 | 42 | return documentToUpdate; 43 | } 44 | } 45 | 46 | export default CalculateValueToUpdate; 47 | -------------------------------------------------------------------------------- /src/modules/shared/utils/error-messages/messages.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BUDGET_DESCRIPTION_MAX_LENGTH, 3 | BUDGET_DESCRIPTION_MIN_LENGTH 4 | } from "@modules/budget-box/domain/budget-description.value-object"; 5 | import { 6 | REASON_DESCRIPTION_MAX_LENGTH, 7 | REASON_DESCRIPTION_MIN_LENGTH 8 | } from "@modules/budget-box/domain/reason-description.value-object"; 9 | import { TRANSACTION_CALCULATION_MIN_VALUE } from "@modules/transaction/domain/transaction-calculations.value-object"; 10 | import { TRANSACTION_NOTE_MAX_LENGTH } from "@modules/transaction/domain/transaction-note.value-object"; 11 | import { 12 | TRANSACTION_DESCRIPTION_MAX_LENGTH, TRANSACTION_DESCRIPTION_MIN_LENGTH 13 | } from "@modules/transaction/domain/transaction-reason.value-object"; 14 | 15 | 16 | export const ErrorMessages = { 17 | INVALID_BUDGET_DESCRIPTION_LENGTH: `Invalid budget description length, must have min ${BUDGET_DESCRIPTION_MIN_LENGTH} and max ${BUDGET_DESCRIPTION_MAX_LENGTH} characters`, 18 | INVALID_PASSWORD_LENGTH: `Password must have min 8 char and max 18 char`, 19 | INVALID_REASON_DESCRIPTION_LENGTH: `Invalid reason description length, must have min ${REASON_DESCRIPTION_MIN_LENGTH} and max ${REASON_DESCRIPTION_MAX_LENGTH} characters`, 20 | INVALID_TRANSACTION_REASON_DESCRIPTION_LENGTH: `Invalid reason description length, must have min ${TRANSACTION_DESCRIPTION_MIN_LENGTH} and max ${TRANSACTION_DESCRIPTION_MAX_LENGTH} characters`, 21 | INVALID_TRANSACTION_CALCULATION_VALUE: `Value must be greater or equal to ${TRANSACTION_CALCULATION_MIN_VALUE}`, 22 | INVALID_TRANSACTION_NOTE_LENGTH: `Note value must have max ${TRANSACTION_NOTE_MAX_LENGTH} characters`, 23 | INVALID_PERCENTAGE_VALUE: 'Invalid range value to percentage', 24 | INVALID_ATTACHMENT_PATH: 'Invalid attachment path or url', 25 | INVALID_ENUM_TRANSACTION_STATUS: 'Invalid transaction enum status', 26 | INVALID_ENUM_TRANSACTION_TYPE: 'Invalid transaction enum type', 27 | INVALID_EMAIL: 'Invalid email', 28 | INVALID_IP: 'Invalid ip', 29 | INVALID_CREDENTIALS: 'Invalid Credentials' 30 | }; 31 | -------------------------------------------------------------------------------- /src/modules/shared/utils/tests/calculate.spec.ts: -------------------------------------------------------------------------------- 1 | import { CURRENCY } from "@config/env"; 2 | import { IBoxes } from "@modules/shared/domain/update-budget-box-balance.domain-service"; 3 | import IBudgetBox from "@modules/shared/interfaces/budget-box-model.interface"; 4 | import { CurrencyValueObject, DomainId } from "types-ddd"; 5 | import CalculateValueToUpdate from "../calculate"; 6 | 7 | describe('calculate', () => { 8 | 9 | const currentDate = new Date('2020-01-01 00:00:00'); 10 | 11 | const makeBudgetBoxModel = (value: number, index: number): IBudgetBox => ({ 12 | id: 'valid_id_' + index, 13 | balanceAvailable: { 14 | currency: CURRENCY, 15 | value 16 | }, 17 | budgetPercentage: 10, 18 | createdAt: currentDate, 19 | description: 'valid_description', 20 | isPercentage: true, 21 | ownerId: 'valid_id', 22 | reasons: [], 23 | updatedAt: currentDate 24 | }); 25 | 26 | const makeDto = (value: number, index: number): IBoxes => ({ 27 | budgetBoxId: DomainId.create('valid_id_' + index), 28 | value: CurrencyValueObject.create({ 29 | value, 30 | currency: CURRENCY 31 | }).getResult() 32 | }); 33 | 34 | it('should calculate with success', () => { 35 | 36 | const budgetBoxFromDataBase = [10, 20, 30].map(makeBudgetBoxModel); 37 | const budgetBoxesDto = [20, 40].map(makeDto); 38 | const expectedResult = [30,60,30].map(makeBudgetBoxModel); 39 | 40 | const calculator = new CalculateValueToUpdate(); 41 | 42 | const result = calculator.calc({ 43 | budgetBoxFromDataBase, 44 | budgetBoxesDto, 45 | operationType: 'SUM' 46 | }); 47 | 48 | expect(result).toEqual(expectedResult); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/modules/transaction/application/mocks/transaction-query-service.mock.ts: -------------------------------------------------------------------------------- 1 | import { ITransactionQueryService } from "@modules/transaction/infra/services/queries/transaction-query.interface"; 2 | 3 | export const transactionQueryServiceMock: ITransactionQueryService = { 4 | getTransactionsByUserId: jest.fn(), 5 | getTransactionById: jest.fn(), 6 | }; 7 | 8 | export default transactionQueryServiceMock; 9 | -------------------------------------------------------------------------------- /src/modules/transaction/application/mocks/transaction-repo.mock.ts: -------------------------------------------------------------------------------- 1 | import ITransactionRepository from "@modules/transaction/domain/interfaces/transaction.repository.interface"; 2 | 3 | export const transactionMockRepo: ITransactionRepository = { 4 | delete: jest.fn(), 5 | exists: jest.fn(), 6 | find: jest.fn(), 7 | findOne: jest.fn(), 8 | save: jest.fn(), 9 | }; 10 | 11 | export default transactionMockRepo; 12 | -------------------------------------------------------------------------------- /src/modules/transaction/application/use-cases/balance-transference/balance-transference.dto.ts: -------------------------------------------------------------------------------- 1 | export interface BalanceTransferenceDto { 2 | userId: string; 3 | sourceBoxId: string; 4 | destinationBoxId: string; 5 | total: number; 6 | reason: string; 7 | attachment?: string; 8 | note?: string; 9 | } 10 | 11 | export default BalanceTransferenceDto; 12 | -------------------------------------------------------------------------------- /src/modules/transaction/application/use-cases/create-expense/create-expense.dto.ts: -------------------------------------------------------------------------------- 1 | import { transactionStatus } from "@modules/transaction/domain/transaction-status.value-object"; 2 | 3 | export interface CreateExpenseDto { 4 | userId: string; 5 | budgetBoxId: string; 6 | total: number; 7 | paymentDate?: Date; 8 | reason: string; 9 | status: transactionStatus; 10 | attachment?: string; 11 | note?: string; 12 | } 13 | 14 | export default CreateExpenseDto; 15 | -------------------------------------------------------------------------------- /src/modules/transaction/application/use-cases/get-transaction-by-id/get-transaction-by-id.dto.ts: -------------------------------------------------------------------------------- 1 | export interface GetTransactionByIdDto { 2 | userId: string; 3 | transactionId: string; 4 | } 5 | 6 | export default GetTransactionByIdDto; 7 | -------------------------------------------------------------------------------- /src/modules/transaction/application/use-cases/get-transaction-by-id/get-transaction-by-id.use-case.ts: -------------------------------------------------------------------------------- 1 | import { ITransactionQueryService } from "@modules/transaction/infra/services/queries/transaction-query.interface"; 2 | import { ITransaction } from "@modules/shared"; 3 | import { Inject } from "@nestjs/common"; 4 | import { IUseCase, Result } from "types-ddd"; 5 | import Dto from "./get-transaction-by-id.dto"; 6 | 7 | export class GetTransactionByIdUseCase implements IUseCase>{ 8 | 9 | constructor ( 10 | @Inject('TransactionQueryService') 11 | private readonly transactionQueryService: ITransactionQueryService 12 | ){} 13 | 14 | async execute ({ userId, transactionId: id }: Dto) :Promise> { 15 | try { 16 | 17 | const transactionOrNull = await this.transactionQueryService.getTransactionById({ userId, id }); 18 | 19 | if (!transactionOrNull) { 20 | return Result.fail('Transaction Not Found', 'NOT_FOUND'); 21 | } 22 | 23 | return Result.ok(transactionOrNull); 24 | 25 | } catch (error) { 26 | return Result.fail( 27 | 'Internal Server Error On Get Transaction By Id Use Case', 28 | 'INTERNAL_SERVER_ERROR' 29 | ); 30 | } 31 | }; 32 | } 33 | 34 | export default GetTransactionByIdUseCase; 35 | -------------------------------------------------------------------------------- /src/modules/transaction/application/use-cases/get-transaction-by-user-id/get-transactions-by-user-id-use-case.spec.ts: -------------------------------------------------------------------------------- 1 | import { ITransactionQueryService } from "@modules/transaction/infra/services/queries/transaction-query.interface"; 2 | import transactionQueryServiceMock from "@modules/transaction/application/mocks/transaction-query-service.mock"; 3 | import { GetTransactionsByUserIdUseCase } from "./get-transactions-by-user-id.use-case"; 4 | 5 | describe('get-transactions-by-user-id.use-case', () => { 6 | 7 | const fakeQueryService: ITransactionQueryService = transactionQueryServiceMock; 8 | 9 | it('should get transactions with success', async () => { 10 | 11 | jest.spyOn(fakeQueryService, 'getTransactionsByUserId').mockResolvedValueOnce([]); 12 | const useCase = new GetTransactionsByUserIdUseCase(fakeQueryService); 13 | 14 | const result = await useCase.execute({ userId: 'valid_id' }); 15 | 16 | expect(result.getResult()).toEqual([]); 17 | }); 18 | 19 | it('should return fails if query service throws', async () => { 20 | 21 | jest.spyOn(fakeQueryService, 'getTransactionsByUserId').mockImplementationOnce(async () => { 22 | throw new Error("error"); 23 | }); 24 | const useCase = new GetTransactionsByUserIdUseCase(fakeQueryService); 25 | 26 | const result = await useCase.execute({ userId: 'valid_id' }); 27 | 28 | expect(result.isFailure).toBeTruthy(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/modules/transaction/application/use-cases/get-transaction-by-user-id/get-transactions-by-user-id.dto.ts: -------------------------------------------------------------------------------- 1 | export interface GetTransactionsByUserIdDto { 2 | userId: string; 3 | beforeDate?: Date; 4 | } 5 | 6 | export default GetTransactionsByUserIdDto; 7 | -------------------------------------------------------------------------------- /src/modules/transaction/application/use-cases/get-transaction-by-user-id/get-transactions-by-user-id.use-case.ts: -------------------------------------------------------------------------------- 1 | import { ITransaction } from "@modules/shared"; 2 | import { ITransactionQueryService } from "@modules/transaction/infra/services/queries/transaction-query.interface"; 3 | import { Inject } from "@nestjs/common"; 4 | import { IUseCase, Result } from "types-ddd"; 5 | import Dto from "./get-transactions-by-user-id.dto"; 6 | 7 | export class GetTransactionsByUserIdUseCase implements IUseCase>{ 8 | 9 | constructor ( 10 | @Inject('TransactionQueryService') 11 | private readonly transactionQueryService: ITransactionQueryService 12 | ){} 13 | 14 | async execute (dto: Dto) : Promise> { 15 | try { 16 | 17 | const data = await this.transactionQueryService.getTransactionsByUserId(dto); 18 | 19 | return Result.ok(data); 20 | } catch (error) { 21 | return Result.fail( 22 | 'Internal Server Error on Get Transactions By User Id UseCase', 23 | 'INTERNAL_SERVER_ERROR' 24 | ); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/modules/transaction/application/use-cases/percentage-capital-inflow-posting/percentage-capital-inflow-posting.dto.ts: -------------------------------------------------------------------------------- 1 | import { transactionStatus } from "@modules/transaction/domain/transaction-status.value-object"; 2 | 3 | export interface PercentageCapitalInflowPostingDto { 4 | total: number; 5 | userId: string; 6 | reason: string; 7 | status: transactionStatus; 8 | paymentDate?: Date; 9 | note?: string; 10 | attachment?: string; 11 | } 12 | 13 | export default PercentageCapitalInflowPostingDto; 14 | -------------------------------------------------------------------------------- /src/modules/transaction/application/use-cases/posting-to-benefit/posting-to-benefit.dto.ts: -------------------------------------------------------------------------------- 1 | import { transactionStatus } from "@modules/transaction/domain/transaction-status.value-object"; 2 | 3 | export interface PostingToBenefitDto { 4 | userId: string; 5 | budgetBoxId: string; 6 | total: number; 7 | reason: string; 8 | status: transactionStatus; 9 | paymentDate?: Date; 10 | attachment?: string; 11 | note?: string; 12 | } 13 | 14 | export default PostingToBenefitDto; 15 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/README.md: -------------------------------------------------------------------------------- 1 | # Transaction - Aggregate 2 | 3 | --- 4 | 5 | Este agregado identifica cada lançamento do usuário, seja entrada ou saída. 6 | O total deve ser a soma dos valores de cada item na lista de calculos da transação. 7 | Só pode haver dois tipos de lançamento: "Entrada" ou "Saida". 8 | 9 | ```json 10 | { 11 | "user-id": "uuid", 12 | "reason": "valid_reason", 13 | "payment-date": "2021-01-01 10:00:00", 14 | "transaction-type": "Entrada | Saida", 15 | "status": "Pendente | Concluído | Estornado", 16 | "note": "valid_description", 17 | "attachment": "url", 18 | "total-value": { 19 | "value": 100, 20 | "currency": "BRL" 21 | }, 22 | "transaction-calculations": [ 23 | { 24 | "budgetBoxName": "valid_name", 25 | "budgetbox-id": "uuid", 26 | "Value": 50 27 | }, 28 | { 29 | "budgetBoxName": "valid_name", 30 | "budgetbox-id": "uuid", 31 | "Value": 50 32 | } 33 | ] 34 | } 35 | ``` 36 | 37 | - transaction-type: Value Object (enum) - Ok 38 | - status: Value Object (enum) - Ok 39 | - note: Value Object - Ok 40 | - attachment-path: Value Object - Ok 41 | - transaction-calculations: Value Object - Ok 42 | - payment-date: Value Object - Ok 43 | - budgetBoxName: Value Object - Ok 44 | - reason: Value Object - Ok 45 | 46 | ### Fluxo de transações 47 | 48 | #### Realizar um lançamento de entrada em um caixa de benefício 49 | 50 | imagem 51 | 52 | #### Realizar um lançamento de entrada distribuido para todos os caixas que estão marcados como percentual 53 | 54 | imagem 55 | 56 | #### Realizar um lançamento de saída (despesa) em algum caixa 57 | 58 | imagem 59 | 60 | #### Realizar um lançamento de transferência de saldo entre caixas 61 | 62 | imagem -------------------------------------------------------------------------------- /src/modules/transaction/domain/attachment-path.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import isURL from 'validator/lib/isURL'; 3 | import { ValueObject, Result } from 'types-ddd'; 4 | const validateDirectoryPath = /^(.+)\/([^\/]+)$/; 5 | 6 | export interface AttachmentPathValueObjectProps { 7 | value: string; 8 | } 9 | 10 | export class AttachmentPathValueObject extends ValueObject { 11 | private constructor (props: AttachmentPathValueObjectProps) { 12 | super(props); 13 | } 14 | 15 | get value (): string { 16 | return this.props.value; 17 | } 18 | 19 | public static isValidValue (value: string): boolean { 20 | const isValidUrl = isURL(value); 21 | const isValidDirectory = validateDirectoryPath.test(value); 22 | const isValid = isValidUrl || isValidDirectory; 23 | return isValid; 24 | } 25 | 26 | /** 27 | * 28 | * @param path url or directory path 29 | * @returns instance of `AttachmentPathValueObject` 30 | */ 31 | public static create (path: string): Result { 32 | const isValidUrl = AttachmentPathValueObject.isValidValue(path); 33 | 34 | if (!isValidUrl) { 35 | return Result.fail( 36 | ErrorMessages.INVALID_ATTACHMENT_PATH, 37 | ); 38 | } 39 | 40 | return Result.ok( 41 | new AttachmentPathValueObject({ value: path }), 42 | ); 43 | } 44 | } 45 | 46 | export default AttachmentPathValueObject; 47 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/budget-box-name.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from "@modules/shared"; 2 | import { Result, ValueObject } from "types-ddd"; 3 | 4 | export const TRANSACTION_BUDGET_BOX_NAME_MAX_LENGTH = 30; 5 | export const TRANSACTION_BUDGET_BOX_NAME_MIN_LENGTH = 3; 6 | 7 | interface Props { 8 | value: string 9 | } 10 | 11 | export class TransactionBudgetBoxNameValueObject extends ValueObject{ 12 | private constructor (props: Props) { 13 | super(props); 14 | } 15 | 16 | get value (): string { 17 | return this.props.value.toLowerCase(); 18 | } 19 | 20 | public static isValidValue (value: string): boolean { 21 | return value.length <= TRANSACTION_BUDGET_BOX_NAME_MAX_LENGTH && 22 | value.length >= TRANSACTION_BUDGET_BOX_NAME_MIN_LENGTH; 23 | } 24 | 25 | public static create (value: string): Result { 26 | const isValidValue = this.isValidValue(value); 27 | 28 | if (!isValidValue) { 29 | return Result.fail(ErrorMessages.INVALID_BUDGET_DESCRIPTION_LENGTH); 30 | } 31 | 32 | return Result.ok(new TransactionBudgetBoxNameValueObject({ value })); 33 | } 34 | } 35 | 36 | export default TransactionBudgetBoxNameValueObject; 37 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/event/transaction-created.event.ts: -------------------------------------------------------------------------------- 1 | import { IDomainEvent, UniqueEntityID } from "types-ddd"; 2 | import TransactionAggregate from "@modules/transaction/domain/transaction.aggregate"; 3 | 4 | export class TransactionCreatedEvent implements IDomainEvent { 5 | public dateTimeOccurred: Date; 6 | public transaction: TransactionAggregate; 7 | 8 | constructor (transaction: TransactionAggregate) { 9 | this.transaction = transaction; 10 | this.dateTimeOccurred = new Date(); 11 | } 12 | 13 | getAggregateId (): UniqueEntityID { 14 | return this.transaction.id.value; 15 | } 16 | 17 | } 18 | 19 | export default TransactionCreatedEvent; 20 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/interfaces/transaction.repository.interface.ts: -------------------------------------------------------------------------------- 1 | import { ITransaction } from "@modules/shared"; 2 | import { IBaseRepository } from "types-ddd"; 3 | import TransactionAggregate from "../transaction.aggregate"; 4 | 5 | export type ITransactionRepository = IBaseRepository; 6 | 7 | export default ITransactionRepository; 8 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/services/can-create-benefit.proxy.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxConnection, IDomainService } from "@modules/shared"; 2 | import { Inject } from "@nestjs/common"; 3 | import { Result } from "types-ddd"; 4 | 5 | export interface CanCreateBenefitDto { 6 | userId: string; 7 | budgetBoxId: string; 8 | } 9 | 10 | export class CanCreateBenefit implements IDomainService> { 11 | constructor ( 12 | @Inject('BudgetBoxConnection') 13 | private readonly budgetBoxConnection: IBudgetBoxConnection 14 | ){} 15 | async execute (data: CanCreateBenefitDto): Promise> { 16 | try { 17 | 18 | const budgetBox = await this.budgetBoxConnection.findBudgetBoxByIdAndUserId({ 19 | id: data.budgetBoxId, ownerId: data.userId 20 | }); 21 | 22 | if (!budgetBox) { 23 | return Result.fail('Budget Box Does Not Exists', 'USE_PROXY'); 24 | } 25 | 26 | const isPercentage = budgetBox.isPercentage; 27 | 28 | if (isPercentage) { 29 | return Result.fail('This Budget Box is calculated by Percentage', 'USE_PROXY'); 30 | } 31 | 32 | return Result.ok(!isPercentage); 33 | } catch (error) { 34 | return Result.fail('Internal Server Error On CanCreateBenefit Proxy', 'INTERNAL_SERVER_ERROR'); 35 | } 36 | } 37 | } 38 | 39 | export default CanCreateBenefit; 40 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/services/can-create-expense.proxy.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxConnection, IDomainService } from "@modules/shared"; 2 | import { Inject } from "@nestjs/common"; 3 | import { Result } from "types-ddd"; 4 | 5 | export interface CanCreateBenefitDto { 6 | userId: string; 7 | budgetBoxId: string; 8 | total: number; 9 | } 10 | 11 | export class CanCreateExpense implements IDomainService> { 12 | constructor ( 13 | @Inject('BudgetBoxConnection') 14 | private readonly budgetBoxConnection: IBudgetBoxConnection 15 | ){} 16 | async execute (data: CanCreateBenefitDto): Promise> { 17 | try { 18 | 19 | const budgetBox = await this.budgetBoxConnection.findBudgetBoxByIdAndUserId({ 20 | id: data.budgetBoxId, ownerId: data.userId 21 | }); 22 | 23 | if (!budgetBox) { 24 | return Result.fail('Budget Box Does Not Exists', 'USE_PROXY'); 25 | } 26 | 27 | const enoughBalance = budgetBox.balanceAvailable.value >= data.total; 28 | 29 | if (!enoughBalance) { 30 | return Result.fail(`Insufficient Funds. Available: ${budgetBox.balanceAvailable.value}`, 'USE_PROXY'); 31 | } 32 | 33 | return Result.ok(enoughBalance); 34 | 35 | } catch (error) { 36 | return Result.fail('Internal Server Error On CanCreateExpense Proxy', 'INTERNAL_SERVER_ERROR'); 37 | } 38 | } 39 | } 40 | 41 | export default CanCreateExpense; 42 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/services/can-transfer.proxy.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxConnection, IDomainService } from "@modules/shared"; 2 | import { Inject } from "@nestjs/common"; 3 | import { Result } from "types-ddd"; 4 | 5 | export interface CanTransferDto { 6 | userId: string; 7 | sourceBoxId: string; 8 | destinationBoxId: string; 9 | total: number; 10 | } 11 | 12 | export class CanTransfer implements IDomainService> { 13 | constructor ( 14 | @Inject('BudgetBoxConnection') 15 | private readonly budgetBoxConnection: IBudgetBoxConnection 16 | ){} 17 | async execute (data: CanTransferDto): Promise> { 18 | try { 19 | 20 | const sourceBox = await this.budgetBoxConnection.findBudgetBoxByIdAndUserId({ 21 | id: data.sourceBoxId, ownerId: data.userId 22 | }); 23 | 24 | const destinationBox = await this.budgetBoxConnection.findBudgetBoxByIdAndUserId({ 25 | id: data.destinationBoxId, ownerId: data.userId 26 | }); 27 | 28 | if (!sourceBox || !destinationBox) { 29 | return Result.fail('Budget Box Does Not Exists', 'USE_PROXY'); 30 | } 31 | 32 | const enoughBalance = sourceBox.balanceAvailable.value >= data.total; 33 | 34 | if (!enoughBalance) { 35 | return Result.fail('The Budget Box Does Not Have Enough Balance', 'USE_PROXY'); 36 | } 37 | 38 | return Result.ok(enoughBalance); 39 | 40 | } catch (error) { 41 | return Result.fail('Internal Server Error On CanTransfer Proxy', 'INTERNAL_SERVER_ERROR'); 42 | } 43 | } 44 | } 45 | 46 | export default CanTransfer; 47 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/services/create-percentage-transaction-calculation.domain-service.ts: -------------------------------------------------------------------------------- 1 | import { CURRENCY } from "@config/env"; 2 | import TransactionCalculationValueObject from "@modules/transaction/domain/transaction-calculations.value-object"; 3 | import TransactionBudgetBoxNameValueObject from "@modules/transaction/domain/budget-box-name.value-object"; 4 | import { IBudgetBoxConnection, IDomainService } from "@modules/shared"; 5 | import { Inject } from "@nestjs/common"; 6 | import { CurrencyValueObject, DomainId } from "types-ddd"; 7 | 8 | export interface PercentageCalculationDomainServiceDto { 9 | userId: string; 10 | total: number; 11 | } 12 | 13 | export class CreatePercentageTransactionCalculationDomainService implements 14 | IDomainService{ 15 | 16 | constructor ( 17 | @Inject('BudgetBoxConnection') 18 | private readonly budgetBoxConnection: IBudgetBoxConnection 19 | ){} 20 | 21 | async execute ({ userId, total }: PercentageCalculationDomainServiceDto): Promise { 22 | const allBudgetBoxes = await this.budgetBoxConnection.findBudgetBoxesByUserId(userId); 23 | const budgetBoxes = allBudgetBoxes.filter((budgetBox) => budgetBox.isPercentage); 24 | 25 | const transactions = budgetBoxes.map((budgetBox) => { 26 | 27 | const currency = CurrencyValueObject 28 | .create({ currency: CURRENCY, value: total }) 29 | .getResult().multiplyBy(budgetBox.budgetPercentage) 30 | .getResult().divideBy(100).getResult(); 31 | 32 | const budgetBoxId = DomainId.create(budgetBox.id); 33 | 34 | const budgetBoxName = TransactionBudgetBoxNameValueObject 35 | .create(budgetBox.description).getResult(); 36 | 37 | const transaction = TransactionCalculationValueObject.create({ 38 | budgetBoxId, 39 | budgetBoxName, 40 | currency 41 | }); 42 | 43 | return transaction.getResult(); 44 | }); 45 | 46 | return transactions; 47 | } 48 | 49 | } 50 | 51 | export default CreatePercentageTransactionCalculationDomainService; 52 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/services/create-single-calculation.domain-service.ts: -------------------------------------------------------------------------------- 1 | import { CURRENCY } from "@config/env"; 2 | import { IBudgetBoxConnection, IDomainService } from "@modules/shared"; 3 | import TransactionCalculationValueObject from "@modules/transaction/domain/transaction-calculations.value-object"; 4 | import { Inject } from "@nestjs/common"; 5 | import { CurrencyValueObject, DomainId } from "types-ddd"; 6 | import TransactionBudgetBoxNameValueObject from "@modules/transaction/domain/budget-box-name.value-object"; 7 | 8 | export interface CreateSingleCalculationDto { 9 | userId: string; 10 | budgetBoxId: string; 11 | total: number; 12 | } 13 | 14 | export class CreateSingleCalculationDomainService implements IDomainService { 15 | constructor ( 16 | @Inject('BudgetBoxConnection') 17 | private readonly budgetBoxConnection: IBudgetBoxConnection 18 | ){} 19 | async execute (data: CreateSingleCalculationDto): Promise { 20 | const budgetBox = await this.budgetBoxConnection.findBudgetBoxByIdAndUserId({ 21 | id: data.budgetBoxId, ownerId: data.userId 22 | }); 23 | 24 | const transaction = TransactionCalculationValueObject.create({ 25 | budgetBoxId: DomainId.create(data.budgetBoxId), 26 | budgetBoxName: TransactionBudgetBoxNameValueObject 27 | .create(budgetBox?.description ?? 'operational').getResult(), 28 | currency: CurrencyValueObject.create({ value: data.total, currency: CURRENCY }).getResult() 29 | }); 30 | 31 | return transaction.getResult(); 32 | } 33 | } 34 | 35 | export default CreateSingleCalculationDomainService; 36 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/services/tests/__snapshots__/create-percentage-transaction-calculation-domain-service.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`create-percentage-transaction-calculation.domain-service should calculate percentage for each budget box 1`] = ` 4 | Array [ 5 | Object { 6 | "budgetBoxId": "valid_id-10", 7 | "budgetBoxName": "valid_name-10", 8 | "currency": Object { 9 | "currency": "BRL", 10 | "value": 10, 11 | }, 12 | }, 13 | Object { 14 | "budgetBoxId": "valid_id-10", 15 | "budgetBoxName": "valid_name-10", 16 | "currency": Object { 17 | "currency": "BRL", 18 | "value": 10, 19 | }, 20 | }, 21 | Object { 22 | "budgetBoxId": "valid_id-20", 23 | "budgetBoxName": "valid_name-20", 24 | "currency": Object { 25 | "currency": "BRL", 26 | "value": 20, 27 | }, 28 | }, 29 | Object { 30 | "budgetBoxId": "valid_id-20", 31 | "budgetBoxName": "valid_name-20", 32 | "currency": Object { 33 | "currency": "BRL", 34 | "value": 20, 35 | }, 36 | }, 37 | Object { 38 | "budgetBoxId": "valid_id-40", 39 | "budgetBoxName": "valid_name-40", 40 | "currency": Object { 41 | "currency": "BRL", 42 | "value": 40, 43 | }, 44 | }, 45 | ] 46 | `; 47 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/services/tests/create-percentage-transaction-calculation-domain-service.spec.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBox, IBudgetBoxConnection } from "@modules/shared"; 2 | import budgetBoxConnectionMock from "@modules/shared/domain/tests/mocks/budget-box-connection.mock"; 3 | import CreatePercentageTransactionCalculationDomainService from "../create-percentage-transaction-calculation.domain-service"; 4 | 5 | describe('create-percentage-transaction-calculation.domain-service', () => { 6 | 7 | let fakeConnection: IBudgetBoxConnection; 8 | 9 | beforeEach(() => { 10 | fakeConnection = budgetBoxConnectionMock; 11 | }); 12 | 13 | it('should calculate percentage for each budget box', async () => { 14 | 15 | const data = [10, 10, 20, 20, 40].map((val) => ({ 16 | id: `valid_id-${val}`, 17 | isPercentage: true, 18 | description: `valid_name-${val}`, 19 | budgetPercentage: val 20 | })); 21 | 22 | const invalidModel = { 23 | id: 'invalid_id', 24 | isPercentage: false, 25 | description: 'valid_name', 26 | budgetPercentage: 100 27 | }; 28 | 29 | jest.spyOn(fakeConnection, 'findBudgetBoxesByUserId').mockResolvedValueOnce( 30 | [...data, invalidModel] as IBudgetBox[] 31 | ); 32 | 33 | const domainService = new CreatePercentageTransactionCalculationDomainService(fakeConnection); 34 | 35 | const result = await domainService.execute({ 36 | total: 100, 37 | userId: 'valid_id' 38 | }); 39 | 40 | const calculation = result.map((calc) => calc.toObject()); 41 | 42 | const total = result.reduce((total, calculation) => total + calculation.currency.value, 0); 43 | expect(total).toBe(100); 44 | expect(calculation).toHaveLength(5); 45 | expect(calculation).toMatchSnapshot(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/services/tests/create-single-calculation-domain-service.spec.ts: -------------------------------------------------------------------------------- 1 | import { IBudgetBoxConnection } from "@modules/shared"; 2 | import TransactionCalculationValueObject from "@modules/transaction/domain/transaction-calculations.value-object"; 3 | import CreateBenefitCalculationDomainService from "@modules/transaction/domain/services/create-single-calculation.domain-service"; 4 | import budgetBoxConnectionMock from "@modules/shared/domain/tests/mocks/budget-box-connection.mock"; 5 | 6 | describe('create-single-calculation.domain-service', () => { 7 | 8 | const fakeConnection: IBudgetBoxConnection = budgetBoxConnectionMock; 9 | 10 | it('should create a valid calculation if budget box is not found', async () => { 11 | 12 | jest.spyOn(fakeConnection, 'findBudgetBoxByIdAndUserId').mockResolvedValueOnce(null); 13 | const domainService = new CreateBenefitCalculationDomainService(fakeConnection); 14 | 15 | const result = await domainService.execute({ 16 | budgetBoxId: 'valid_id', 17 | total: 100, 18 | userId: 'valid_id' 19 | }); 20 | 21 | expect(result).toBeInstanceOf(TransactionCalculationValueObject); 22 | expect(result.budgetBoxName.value).toBe('operational'); 23 | expect(result.currency.value).toBe(100); 24 | }); 25 | 26 | it('should create a valid calculation', async () => { 27 | 28 | jest.spyOn(fakeConnection, 'findBudgetBoxByIdAndUserId').mockResolvedValueOnce({ description: 'valid' } as any); 29 | const domainService = new CreateBenefitCalculationDomainService(fakeConnection); 30 | 31 | const result = await domainService.execute({ 32 | budgetBoxId: 'valid_id', 33 | total: 100, 34 | userId: 'valid_id' 35 | }); 36 | 37 | expect(result).toBeInstanceOf(TransactionCalculationValueObject); 38 | expect(result.budgetBoxName.value).toBe('valid'); 39 | expect(result.currency.value).toBe(100); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/subscriptions/after-transaction-created-subscription.spec.ts: -------------------------------------------------------------------------------- 1 | import { IDomainService } from "@modules/shared"; 2 | import { UpdateBudgetBoxBalanceDto } from "@modules/shared/domain/update-budget-box-balance.domain-service"; 3 | import { Result } from "types-ddd"; 4 | import TransactionCreatedEvent from "../event/transaction-created.event"; 5 | import TransactionMock from "../tests/mock/transaction.mock"; 6 | import AfterTransactionCreated from "./after-transaction-created.subscription"; 7 | 8 | describe('after-transaction-created.subscription', () => { 9 | 10 | let fakeUpdateBudgetBoxDomainService: IDomainService> = { 11 | execute: jest.fn() 12 | }; 13 | 14 | beforeEach(() => { 15 | fakeUpdateBudgetBoxDomainService = { 16 | execute: jest.fn() 17 | }; 18 | }); 19 | 20 | const transactionMock = new TransactionMock(); 21 | 22 | it('should dispatch with success', async () => { 23 | 24 | jest.spyOn(fakeUpdateBudgetBoxDomainService, 'execute').mockResolvedValueOnce(Result.ok(true)); 25 | const serviceSpy = jest.spyOn(fakeUpdateBudgetBoxDomainService, 'execute'); 26 | 27 | const transaction = transactionMock.domain( 28 | { 29 | id: 'valid_id', 30 | transactionType: 'ENTRADA', 31 | } 32 | ).getResult(); 33 | 34 | const event = new AfterTransactionCreated(fakeUpdateBudgetBoxDomainService); 35 | 36 | await event.dispatch(new TransactionCreatedEvent(transaction)); 37 | 38 | expect(serviceSpy).toHaveBeenCalled(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/subscriptions/after-transaction-created.subscription.ts: -------------------------------------------------------------------------------- 1 | import { IDomainService } from "@modules/shared"; 2 | import { 3 | IBoxes, OperationType, UpdateBudgetBoxBalanceDto 4 | } from "@modules/shared/domain/update-budget-box-balance.domain-service"; 5 | import TransactionCreatedEvent from "@modules/transaction/domain/event/transaction-created.event"; 6 | import { Inject } from "@nestjs/common"; 7 | import { DomainEvents, IHandle, Logger, Result } from "types-ddd"; 8 | import { transactionType } from "@modules/transaction/domain/transaction-type.value-object"; 9 | type Operations = { [k in transactionType]: OperationType }; 10 | 11 | export class AfterTransactionCreated implements IHandle{ 12 | 13 | constructor ( 14 | @Inject('UpdateBudgetBoxBalanceDomainService') 15 | private readonly updateBudgetBoxBalanceDomainService: IDomainService> 16 | ) { 17 | this.setupSubscriptions(); 18 | } 19 | 20 | setupSubscriptions (): void { 21 | DomainEvents.register( 22 | (event) => this.dispatch(Object.assign(event)), 23 | TransactionCreatedEvent.name 24 | ); 25 | } 26 | 27 | async dispatch (event: TransactionCreatedEvent): Promise { 28 | 29 | const transaction = event.transaction; 30 | 31 | const operations: Operations = { 32 | ENTRADA: "SUM", 33 | SAIDA: "SUBTRACT", 34 | ESTORNO: "NONE", 35 | TRANSFERENCIA: "NONE" 36 | }; 37 | 38 | const budgetBoxes: IBoxes[] = transaction.transactionCalculations.map((calc): IBoxes => ({ 39 | budgetBoxId: calc.budgetBoxId, 40 | value: calc.currency 41 | })); 42 | 43 | const operationType = operations[transaction.transactionType.value]; 44 | 45 | const data: UpdateBudgetBoxBalanceDto = { operationType, budgetBoxes }; 46 | 47 | const result = await this.updateBudgetBoxBalanceDomainService.execute(data); 48 | return Logger.info(`Success to update box: ${result.isSuccess}`); 49 | } 50 | } 51 | 52 | export default AfterTransactionCreated; 53 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/tests/__snapshots__/transaction.aggregate.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`transaction.aggregate should create a valid transaction with provided id 1`] = ` 4 | Object { 5 | "attachment": "https://aws.s3.com/bucket-askjdas656/file.pdf", 6 | "createdAt": 2022-01-01T00:00:00.000Z, 7 | "deletedAt": undefined, 8 | "id": "valid_id", 9 | "isDeleted": false, 10 | "note": "valid_description", 11 | "paymentDate": 2022-01-01T00:00:00.000Z, 12 | "reason": "valid_reason", 13 | "status": "CONCLUIDO", 14 | "totalValue": Object { 15 | "currency": "BRL", 16 | "value": 1000, 17 | }, 18 | "transactionCalculations": Array [ 19 | Object { 20 | "budgetBoxId": "valid_budget_box_id", 21 | "budgetBoxName": "valid_name", 22 | "currency": Object { 23 | "currency": "BRL", 24 | "value": 1000, 25 | }, 26 | }, 27 | ], 28 | "transactionType": "ENTRADA", 29 | "updatedAt": 2022-01-01T00:00:00.000Z, 30 | "userId": "valid_user_id", 31 | } 32 | `; 33 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/tests/attachment-path.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { AttachmentPathValueObject } from '../attachment-path.value-object'; 3 | 4 | describe('attachment-path.value-object', () => { 5 | it('should create a valid attachment path if provide an url', () => { 6 | const validUrl = 'https://tyreek.org'; 7 | const attachment = AttachmentPathValueObject.create(validUrl); 8 | expect(attachment.isSuccess).toBe(true); 9 | expect(attachment.getResult().value).toBe(validUrl); 10 | }); 11 | 12 | it('should fail if provide an invalid url', () => { 13 | const attachment = AttachmentPathValueObject.create('invalid_url'); 14 | expect(attachment.isSuccess).toBe(false); 15 | expect(attachment.error).toBe(ErrorMessages.INVALID_ATTACHMENT_PATH); 16 | }); 17 | 18 | it('should create a valid attachment path if provide a directory', () => { 19 | const attachment = AttachmentPathValueObject.create( 20 | './folder/public/image.jpeg', 21 | ); 22 | expect(attachment.isSuccess).toBe(true); 23 | expect(attachment.getResult().value).toBe('./folder/public/image.jpeg'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/tests/budget-box-name.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import BudgetBoxNameValueObject from "../budget-box-name.value-object"; 2 | 3 | describe('budget-box-name.value-object', () => { 4 | it('should create a valid value object', () => { 5 | const result = BudgetBoxNameValueObject.create('valid Description'); 6 | 7 | expect(result.isSuccess).toBeTruthy(); 8 | expect(result.getResult().value).toBe('valid description'); 9 | }); 10 | 11 | it('should fails if provide an invalid value', () => { 12 | 13 | const invalidMaxValue = 'invalid_value'.repeat(30); 14 | const invalidMinValue = ''; 15 | 16 | const resultMax = BudgetBoxNameValueObject.create(invalidMaxValue); 17 | const resultMin = BudgetBoxNameValueObject.create(invalidMinValue); 18 | 19 | expect(resultMax.isFailure).toBeTruthy(); 20 | expect(resultMin.isFailure).toBeTruthy(); 21 | }); 22 | 23 | }); -------------------------------------------------------------------------------- /src/modules/transaction/domain/tests/transaction-calculations.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { CURRENCY } from '@config/env'; 2 | import { ErrorMessages } from '@shared/index'; 3 | import { CurrencyValueObject, DomainId } from 'types-ddd'; 4 | import TransactionBudgetBoxNameValueObject from '../budget-box-name.value-object'; 5 | import { TransactionCalculationValueObject } from '../transaction-calculations.value-object'; 6 | 7 | describe('transaction-calculations.value-object', () => { 8 | 9 | 10 | const getCurrency = (value: number) => CurrencyValueObject.create({ 11 | currency: CURRENCY, 12 | value 13 | }).getResult(); 14 | 15 | it('should create a valid calculation', () => { 16 | const calculation = TransactionCalculationValueObject.create({ 17 | budgetBoxName: TransactionBudgetBoxNameValueObject.create('valid_name').getResult(), 18 | budgetBoxId: DomainId.create('valid_budgetId'), 19 | currency: getCurrency(200), 20 | }); 21 | 22 | expect(calculation.isSuccess).toBe(true); 23 | expect(calculation.getResult().currency.value).toBe(200); 24 | expect(calculation.getResult().budgetBoxId.uid).toBe('valid_budgetId'); 25 | }); 26 | 27 | it('should fail if provide a negative number', () => { 28 | const calculation = TransactionCalculationValueObject.create({ 29 | budgetBoxName: TransactionBudgetBoxNameValueObject.create('valid_name').getResult(), 30 | budgetBoxId: DomainId.create('valid_budgetId'), 31 | currency: getCurrency(-100), 32 | }); 33 | 34 | 35 | expect(calculation.isSuccess).toBe(false); 36 | expect(calculation.error).toBe( 37 | ErrorMessages.INVALID_TRANSACTION_CALCULATION_VALUE, 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/tests/transaction-note.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@modules/shared'; 2 | import { TransactionNoteValueObject } from '../transaction-note.value-object'; 3 | 4 | describe('transaction-note.value-object', () => { 5 | it('should create a valid note', () => { 6 | const note = TransactionNoteValueObject.create('One_valid_note'); 7 | expect(note.isSuccess).toBe(true); 8 | expect(note.getResult().value).toBe('One_valid_note'); 9 | }); 10 | 11 | it('should fail if provide string greatter than 144 char', () => { 12 | const note = TransactionNoteValueObject.create(`Sequi hic unde qui ut vero aut. 13 | Enim nulla dicta asperiores.Eius dicta delectus natus in sit omnis eveniet ut sapiente.Magnam et magnam. 14 | Consectetur ipsam minus rerum non.`); 15 | 16 | expect(note.isSuccess).toBe(false); 17 | expect(note.error).toBe(ErrorMessages.INVALID_TRANSACTION_NOTE_LENGTH); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/tests/transaction-reason.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import TransactionReasonDescriptionValueObject from "../transaction-reason.value-object"; 2 | 3 | describe('transaction-reason.value-object', () => { 4 | it('should create a valid value object', () => { 5 | const result = TransactionReasonDescriptionValueObject.create('valid Description'); 6 | 7 | expect(result.isSuccess).toBeTruthy(); 8 | expect(result.getResult().value).toBe('valid description'); 9 | }); 10 | 11 | it('should fails if provide an invalid value', () => { 12 | 13 | const invalidMaxValue = 'invalid_value'.repeat(50); 14 | const invalidMinValue = ''; 15 | 16 | const resultMax = TransactionReasonDescriptionValueObject.create(invalidMaxValue); 17 | const resultMin = TransactionReasonDescriptionValueObject.create(invalidMinValue); 18 | 19 | expect(resultMax.isFailure).toBeTruthy(); 20 | expect(resultMin.isFailure).toBeTruthy(); 21 | }); 22 | 23 | it('should always create a valid description', () => { 24 | 25 | const invalidMaxValue = 'invalid_value'.repeat(50); 26 | const invalidMinValue = ''; 27 | const validValue = 'valid_value'; 28 | 29 | const resultMax = TransactionReasonDescriptionValueObject.createValid(invalidMaxValue); 30 | const resultMin = TransactionReasonDescriptionValueObject.createValid(invalidMinValue); 31 | const result = TransactionReasonDescriptionValueObject.createValid(validValue); 32 | 33 | expect(resultMax.value).toBe(invalidMaxValue.slice(0, 49)); 34 | expect(resultMin.value).toBe('auto import: '); 35 | expect(result.value).toBe('valid_value'); 36 | }); 37 | }); -------------------------------------------------------------------------------- /src/modules/transaction/domain/tests/transaction-status.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { TransactionStatusValueObject } from '../transaction-status.value-object'; 3 | 4 | describe('transaction-status.value-object', () => { 5 | it('should create a valid transaction-status', () => { 6 | const result = TransactionStatusValueObject.create('CONCLUIDO'); 7 | 8 | expect(result.isSuccess).toBe(true); 9 | }); 10 | 11 | it('should create a valid transaction-status', () => { 12 | const result = TransactionStatusValueObject.create('pendente' as any); 13 | 14 | expect(result.isSuccess).toBe(true); 15 | expect(result.getResult().value).toBe('PENDENTE'); 16 | }); 17 | 18 | it('should fail if provide an invalid transaction-status as string', () => { 19 | const result = TransactionStatusValueObject.create('INVALID_STATUS' as any); 20 | 21 | expect(result.isSuccess).toBe(false); 22 | expect(result.error).toBe(ErrorMessages.INVALID_ENUM_TRANSACTION_STATUS); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/tests/transaction-type.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { TransactionTypeValueObject } from '../transaction-type.value-object'; 3 | 4 | describe('transaction-type.value-object', () => { 5 | it('should create a valid transaction-type', () => { 6 | const result = TransactionTypeValueObject.create('ENTRADA'); 7 | 8 | expect(result.isSuccess).toBe(true); 9 | }); 10 | 11 | it('should create a valid transaction-type', () => { 12 | const result = TransactionTypeValueObject.create('saida' as any); 13 | 14 | expect(result.isSuccess).toBe(true); 15 | expect(result.getResult().value).toBe('SAIDA'); 16 | }); 17 | 18 | it('should fail if provide an invalid transaction-type as string', () => { 19 | const result = TransactionTypeValueObject.create('INVALID_TYPE' as any); 20 | 21 | expect(result.isSuccess).toBe(false); 22 | expect(result.error).toBe(ErrorMessages.INVALID_ENUM_TRANSACTION_TYPE); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/transaction-calculations.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { Result, ValueObject, DomainId, CurrencyValueObject } from 'types-ddd'; 3 | import TransactionBudgetBoxNameValueObject from './budget-box-name.value-object'; 4 | 5 | export const TRANSACTION_CALCULATION_MIN_VALUE = 0; 6 | 7 | interface calculationProps { 8 | budgetBoxName: TransactionBudgetBoxNameValueObject; 9 | budgetBoxId: DomainId; 10 | currency: CurrencyValueObject; 11 | } 12 | 13 | export class TransactionCalculationValueObject extends ValueObject { 14 | private constructor (props: calculationProps) { 15 | super(props); 16 | } 17 | 18 | get budgetBoxName (): TransactionBudgetBoxNameValueObject { 19 | return this.props.budgetBoxName; 20 | } 21 | 22 | get budgetBoxId (): DomainId { 23 | return this.props.budgetBoxId; 24 | } 25 | 26 | get currency (): CurrencyValueObject { 27 | return this.props.currency; 28 | } 29 | 30 | public static create ( 31 | props: calculationProps, 32 | ): Result { 33 | const isValidValue = props.currency.value >= TRANSACTION_CALCULATION_MIN_VALUE; 34 | 35 | if (!isValidValue) { 36 | return Result.fail( 37 | ErrorMessages.INVALID_TRANSACTION_CALCULATION_VALUE, 38 | ); 39 | } 40 | 41 | return Result.ok( 42 | new TransactionCalculationValueObject({ ...props }), 43 | ); 44 | } 45 | } 46 | 47 | export default TransactionCalculationValueObject; 48 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/transaction-note.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { ValueObject, Result } from 'types-ddd'; 3 | 4 | export const TRANSACTION_NOTE_MAX_LENGTH = 144; 5 | export interface TransactionNoteValueObjectProps { 6 | value: string; 7 | } 8 | 9 | export class TransactionNoteValueObject extends ValueObject { 10 | private constructor (props: TransactionNoteValueObjectProps) { 11 | super(props); 12 | } 13 | 14 | get value (): string { 15 | return this.props.value; 16 | } 17 | 18 | public static isValidValue (value: string): boolean { 19 | return value.length <= TRANSACTION_NOTE_MAX_LENGTH; 20 | } 21 | 22 | public static create (note: string): Result { 23 | 24 | const isValidLength = TransactionNoteValueObject.isValidValue(note); 25 | 26 | if (!isValidLength) { 27 | return Result.fail( 28 | ErrorMessages.INVALID_TRANSACTION_NOTE_LENGTH, 29 | ); 30 | } 31 | 32 | return Result.ok( 33 | new TransactionNoteValueObject({ value: note }), 34 | ); 35 | } 36 | } 37 | 38 | export default TransactionNoteValueObject; 39 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/transaction-reason.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from "@modules/shared"; 2 | import { Result, ValueObject } from "types-ddd"; 3 | 4 | export const TRANSACTION_DESCRIPTION_MAX_LENGTH = 50; 5 | export const TRANSACTION_DESCRIPTION_MIN_LENGTH = 3; 6 | 7 | interface Props { 8 | value: string 9 | } 10 | 11 | export class TransactionReasonDescriptionValueObject extends ValueObject{ 12 | private constructor (props: Props) { 13 | super(props); 14 | } 15 | 16 | get value (): string { 17 | return this.props.value.toLowerCase(); 18 | } 19 | 20 | public static isValidValue (value: string): boolean { 21 | return value.length <= TRANSACTION_DESCRIPTION_MAX_LENGTH && 22 | value.length >= TRANSACTION_DESCRIPTION_MIN_LENGTH; 23 | } 24 | 25 | public static createValid (value: string): TransactionReasonDescriptionValueObject { 26 | 27 | if (value.length < TRANSACTION_DESCRIPTION_MIN_LENGTH) { 28 | return new TransactionReasonDescriptionValueObject({ value: 'auto import: ' + value }); 29 | } 30 | 31 | if (value.length > TRANSACTION_DESCRIPTION_MAX_LENGTH) { 32 | return new TransactionReasonDescriptionValueObject({ 33 | value: value.slice(0, TRANSACTION_DESCRIPTION_MAX_LENGTH - 1) 34 | }); 35 | } 36 | 37 | return new TransactionReasonDescriptionValueObject({ value }); 38 | } 39 | 40 | public static create (value: string): Result { 41 | const isValidValue = this.isValidValue(value); 42 | 43 | if (!isValidValue) { 44 | return Result.fail(ErrorMessages.INVALID_TRANSACTION_REASON_DESCRIPTION_LENGTH); 45 | } 46 | 47 | return Result.ok(new TransactionReasonDescriptionValueObject({ value })); 48 | } 49 | } 50 | 51 | export default TransactionReasonDescriptionValueObject; 52 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/transaction-status.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { ValueObject, Result } from 'types-ddd'; 3 | 4 | export enum validTransactionStatusEnum { 5 | 'PENDENTE' = 'PENDENTE', 6 | 'CONCLUIDO' = 'CONCLUIDO', 7 | 'ESTORNADO' = 'ESTORNADO', 8 | } 9 | 10 | export type transactionStatus = keyof typeof validTransactionStatusEnum; 11 | export interface TransactionStatusValueObjectProps { 12 | value: transactionStatus; 13 | } 14 | 15 | export class TransactionStatusValueObject extends ValueObject { 16 | private constructor (props: TransactionStatusValueObjectProps) { 17 | super(props); 18 | } 19 | 20 | get value (): transactionStatus { 21 | return this.props.value.toUpperCase() as transactionStatus; 22 | } 23 | 24 | public static isValidValue (value: transactionStatus): boolean { 25 | return value.toUpperCase() in validTransactionStatusEnum; 26 | } 27 | 28 | public static create ( 29 | status: transactionStatus, 30 | ): Result { 31 | 32 | const isValidEnumValue = TransactionStatusValueObject.isValidValue(status); 33 | 34 | if (!isValidEnumValue) { 35 | return Result.fail( 36 | ErrorMessages.INVALID_ENUM_TRANSACTION_STATUS, 37 | ); 38 | } 39 | 40 | return Result.ok( 41 | new TransactionStatusValueObject({ value: status }), 42 | ); 43 | } 44 | } 45 | 46 | export default TransactionStatusValueObject; 47 | -------------------------------------------------------------------------------- /src/modules/transaction/domain/transaction-type.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { ValueObject, Result } from 'types-ddd'; 3 | 4 | export enum validTransactionTypeEnum { 5 | 'ENTRADA' = 'ENTRADA', 6 | 'SAIDA' = 'SAIDA', 7 | 'ESTORNO' = 'ESTORNO', 8 | 'TRANSFERENCIA' = 'TRANSFERENCIA' 9 | } 10 | 11 | export type transactionType = keyof typeof validTransactionTypeEnum; 12 | export interface TransactionTypeValueObjectProps { 13 | value: transactionType; 14 | } 15 | 16 | export class TransactionTypeValueObject extends ValueObject { 17 | private constructor (props: TransactionTypeValueObjectProps) { 18 | super(props); 19 | } 20 | 21 | get value (): transactionType { 22 | return this.props.value.toUpperCase() as transactionType; 23 | } 24 | 25 | public static isValidValue (value: transactionType): boolean { 26 | return value.toUpperCase() in validTransactionTypeEnum; 27 | } 28 | 29 | public static create ( 30 | type: transactionType, 31 | ): Result { 32 | 33 | const isValidEnumValue = TransactionTypeValueObject.isValidValue(type); 34 | 35 | if (!isValidEnumValue) { 36 | return Result.fail( 37 | ErrorMessages.INVALID_ENUM_TRANSACTION_TYPE, 38 | ); 39 | } 40 | 41 | return Result.ok( 42 | new TransactionTypeValueObject({ value: type }), 43 | ); 44 | } 45 | } 46 | 47 | export default TransactionTypeValueObject; 48 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/inputs/balance-transference.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, Float, InputType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class BalanceTransferenceInput { 5 | @Field(() => Float) 6 | total!: number; 7 | 8 | @Field(() => String) 9 | reason!: string; 10 | 11 | @Field(() => String) 12 | sourceBoxId!: string; 13 | 14 | @Field(() => String) 15 | destinationBoxId!: string; 16 | 17 | @Field(() => String, { nullable: true }) 18 | note?: string; 19 | 20 | @Field(() => String, { nullable: true }) 21 | attachment?: string; 22 | } 23 | 24 | export default BalanceTransferenceInput; 25 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/inputs/create-expense.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, Float, InputType } from "@nestjs/graphql"; 2 | import { TransactionStatus, ValidClientTransactionStatusEnum } from "../types/transaction.types"; 3 | 4 | @InputType() 5 | export class CreateExpenseInput { 6 | @Field(() => Float) 7 | total!: number; 8 | 9 | @Field(() => String) 10 | reason!: string; 11 | 12 | @Field(() => String) 13 | budgetBoxId!: string; 14 | 15 | @Field(() => ValidClientTransactionStatusEnum) 16 | status!: TransactionStatus; 17 | 18 | @Field(() => Date, { nullable: true }) 19 | paymentDate?: Date; 20 | 21 | @Field(() => String, { nullable: true }) 22 | note?: string; 23 | 24 | @Field(() => String, { nullable: true }) 25 | attachment?: string; 26 | } 27 | 28 | export default CreateExpenseInput; 29 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/inputs/get-transaction-by-id.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from "@nestjs/graphql"; 2 | 3 | @InputType() 4 | export class GetTransactionByIdInput { 5 | @Field(() => String) 6 | transactionId!: string; 7 | } 8 | 9 | export default GetTransactionByIdInput; 10 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/inputs/percentage-capital-inflow-posting.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, Float, InputType } from "@nestjs/graphql"; 2 | import { TransactionStatus, ValidClientTransactionStatusEnum } from "../types/transaction.types"; 3 | 4 | @InputType() 5 | export class PercentageCapitalInflowPostingInput { 6 | @Field(() => Float) 7 | total!: number; 8 | 9 | @Field(() => String) 10 | reason!: string; 11 | 12 | @Field(() => ValidClientTransactionStatusEnum) 13 | status!: TransactionStatus; 14 | 15 | @Field(() => Date, { nullable: true }) 16 | paymentDate?: Date; 17 | 18 | @Field(() => String, { nullable: true }) 19 | note?: string; 20 | 21 | @Field(() => String, { nullable: true }) 22 | attachment?: string; 23 | } 24 | 25 | export default PercentageCapitalInflowPostingInput; 26 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/inputs/posting-to-benefit.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, Float, InputType } from "@nestjs/graphql"; 2 | import { TransactionStatus, ValidClientTransactionStatusEnum } from "../types/transaction.types"; 3 | 4 | @InputType() 5 | export class PostingToBenefitInput { 6 | @Field(() => Float) 7 | total!: number; 8 | 9 | @Field(() => String) 10 | reason!: string; 11 | 12 | @Field(() => String) 13 | budgetBoxId!: string; 14 | 15 | @Field(() => ValidClientTransactionStatusEnum) 16 | status!: TransactionStatus; 17 | 18 | @Field(() => Date, { nullable: true }) 19 | paymentDate?: Date; 20 | 21 | @Field(() => String, { nullable: true }) 22 | note?: string; 23 | 24 | @Field(() => String, { nullable: true }) 25 | attachment?: string; 26 | } 27 | 28 | export default PostingToBenefitInput; 29 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/repo/tests/__snapshots__/transaction-mapper.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`transaction.mapper should translate to domain with success 1`] = ` 4 | Object { 5 | "attachment": "http://s3-us-east-1.amazonaws.com/bucket/file.pdf", 6 | "createdAt": 2022-01-01T00:00:00.000Z, 7 | "deletedAt": undefined, 8 | "id": "valid_id", 9 | "isDeleted": false, 10 | "note": "valid_note", 11 | "paymentDate": 2022-01-01T00:00:00.000Z, 12 | "reason": "valid_reason_id", 13 | "status": "CONCLUIDO", 14 | "totalValue": Object { 15 | "currency": "BRL", 16 | "value": 1000, 17 | }, 18 | "transactionCalculations": Array [ 19 | Object { 20 | "budgetBoxId": "valid_budget_box_id", 21 | "budgetBoxName": "valid_name", 22 | "currency": Object { 23 | "currency": "BRL", 24 | "value": 1000, 25 | }, 26 | }, 27 | ], 28 | "transactionType": "ENTRADA", 29 | "updatedAt": 2022-01-01T00:00:00.000Z, 30 | "userId": "valid_user_id", 31 | } 32 | `; 33 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/repo/tests/transaction-mapper.spec.ts: -------------------------------------------------------------------------------- 1 | import TransactionMock from "@modules/transaction/domain/tests/mock/transaction.mock"; 2 | import TransactionCalculationToDomain from "../transaction-calculation.mapper"; 3 | import TransactionToDomainMapper from "../transaction.mapper"; 4 | 5 | describe('transaction.mapper', () => { 6 | 7 | const mock = new TransactionMock(); 8 | const calcMapper = new TransactionCalculationToDomain(); 9 | const mapper = new TransactionToDomainMapper(calcMapper); 10 | 11 | it('should translate to domain with success', () => { 12 | const model = mock.model(); 13 | const result = mapper.map(model); 14 | const generated = result.getResult().toObject(); 15 | 16 | expect(result.isSuccess).toBeTruthy(); 17 | expect(generated).toEqual(model); 18 | expect(generated).toMatchSnapshot(); 19 | 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/repo/transaction-calculation.mapper.ts: -------------------------------------------------------------------------------- 1 | import { ICalculation } from "@modules/shared"; 2 | import TransactionBudgetBoxNameValueObject from "@modules/transaction/domain/budget-box-name.value-object"; 3 | import TransactionCalculationValueObject from "@modules/transaction/domain/transaction-calculations.value-object"; 4 | import { Injectable } from "@nestjs/common"; 5 | import { CurrencyValueObject, DomainId, Result, TMapper } from "types-ddd"; 6 | 7 | @Injectable() 8 | export class TransactionCalculationToDomain implements TMapper{ 9 | map (target: ICalculation): Result { 10 | 11 | const budgetBoxNameOrError = TransactionBudgetBoxNameValueObject.create(target.budgetBoxName); 12 | const currencyOrError = CurrencyValueObject.create(target.currency); 13 | const budgetBoxId = DomainId.create(target.budgetBoxId); 14 | 15 | return TransactionCalculationValueObject.create({ 16 | budgetBoxId, 17 | budgetBoxName: budgetBoxNameOrError.getResult(), 18 | currency: currencyOrError.getResult(), 19 | }); 20 | }; 21 | } 22 | 23 | export default TransactionCalculationToDomain; 24 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/services/queries/transaction-query.interface.ts: -------------------------------------------------------------------------------- 1 | import { ITransaction } from "@modules/shared"; 2 | 3 | export interface Filter { 4 | userId: string; 5 | beforeDate?: Date; 6 | } 7 | 8 | export interface IData { 9 | userId: string; 10 | id: string; 11 | } 12 | 13 | export interface ITransactionQueryService { 14 | getTransactionsByUserId(filter: Filter): Promise; 15 | getTransactionById(data: IData): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/services/queries/transaction-query.service.ts: -------------------------------------------------------------------------------- 1 | import { Transaction, TransactionDocument } from "@modules/transaction/infra/entities/transaction.schema"; 2 | import { Injectable } from "@nestjs/common"; 3 | import { InjectModel } from "@nestjs/mongoose"; 4 | import { Model } from "mongoose"; 5 | import { ITransaction } from "@modules/shared"; 6 | import { 7 | Filter, 8 | IData, 9 | ITransactionQueryService 10 | } from "@modules/transaction/infra/services/queries/transaction-query.interface"; 11 | 12 | @Injectable() 13 | export class TransactionQueryService implements ITransactionQueryService { 14 | 15 | constructor ( 16 | @InjectModel(Transaction.name) private readonly conn: Model, 17 | ) { } 18 | 19 | async getTransactionsByUserId ({ beforeDate, userId }: Filter): Promise { 20 | 21 | const date = beforeDate ? beforeDate : new Date(); 22 | 23 | const transactions = await this.conn 24 | .find( 25 | { userId, createdAt: { $lt: date } }, 26 | { _id: false, __v: false } 27 | ) 28 | .sort({ createdAt: 'desc' }) 29 | .limit(300); 30 | 31 | return transactions; 32 | } 33 | 34 | async getTransactionById (data: IData): Promise { 35 | const transactionOrNull = await this.conn.findOne({ ...data }).exec(); 36 | 37 | if (!transactionOrNull) { 38 | return null; 39 | } 40 | 41 | return transactionOrNull; 42 | } 43 | } 44 | 45 | export default TransactionQueryService; 46 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/tests/transaction.mutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "graphql-request"; 2 | 3 | export const RESULT = gql``; 4 | 5 | export const CREATE_PERCENTAGE_TRANSACTION = gql` 6 | mutation($PercentageCapitalInflowPostingInput: PercentageCapitalInflowPostingInput!) { 7 | percentageCapitalInflowPosting(PercentageCapitalInflowPostingInput: $PercentageCapitalInflowPostingInput) 8 | }${RESULT} 9 | `; 10 | 11 | export const CREATE_BENEFIT_TRANSACTION = gql` 12 | mutation($PostingToBenefitInput: PostingToBenefitInput!) { 13 | postingToBenefit(PostingToBenefitInput: $PostingToBenefitInput) 14 | }${RESULT} 15 | `; 16 | 17 | export const CREATE_EXPENSE_TRANSACTION = gql` 18 | mutation($CreateExpenseInput: CreateExpenseInput!) { 19 | createExpense(CreateExpenseInput: $CreateExpenseInput) 20 | }${RESULT} 21 | `; 22 | -------------------------------------------------------------------------------- /src/modules/transaction/infra/tests/transaction.query.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "graphql-request"; 2 | 3 | export const TRANSACTION_FRAGMENT = gql` 4 | fragment Transaction on BoxTransactionType { 5 | id 6 | userId 7 | reason 8 | paymentDate 9 | totalValue { 10 | value 11 | currency 12 | } 13 | transactionType 14 | createdAt 15 | } 16 | `; 17 | 18 | export const GET_TRANSACTIONS = gql` 19 | query { 20 | getTransactions { 21 | ...Transaction 22 | } 23 | }${TRANSACTION_FRAGMENT} 24 | `; 25 | 26 | export const GET_TRANSACTION_BY_ID = gql` 27 | query($GetTransactionByIdInput: GetTransactionByIdInput!) { 28 | getTransactionById(GetTransactionByIdInput: $GetTransactionByIdInput){ 29 | ...Transaction 30 | } 31 | }${TRANSACTION_FRAGMENT} 32 | `; 33 | -------------------------------------------------------------------------------- /src/modules/user/application/mocks/user-query-service.mock.ts: -------------------------------------------------------------------------------- 1 | import { IUserQueryService } from "@modules/user/infra/services/queries/user-query.interface"; 2 | 3 | export const userQueryServiceMock: IUserQueryService = { 4 | getUserById: jest.fn() 5 | }; 6 | 7 | export default userQueryServiceMock; 8 | -------------------------------------------------------------------------------- /src/modules/user/application/mocks/user-repository.mock.ts: -------------------------------------------------------------------------------- 1 | import { IUserRepository } from "@modules/user/domain/interfaces/user.repository.interface"; 2 | 3 | export const userRepositoryMock: IUserRepository = { 4 | delete: jest.fn(), 5 | exists: jest.fn(), 6 | find: jest.fn(), 7 | findOne: jest.fn(), 8 | save: jest.fn(), 9 | }; 10 | 11 | export default userRepositoryMock; 12 | -------------------------------------------------------------------------------- /src/modules/user/application/use-cases/delete-account/delete-account.dto.ts: -------------------------------------------------------------------------------- 1 | export interface DeleteAccountDto { 2 | userId: string; 3 | password: string; 4 | } 5 | 6 | export default DeleteAccountDto; 7 | -------------------------------------------------------------------------------- /src/modules/user/application/use-cases/delete-account/delete-account.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IUserRepository } from "@modules/user/domain/interfaces/user.repository.interface"; 2 | import { Inject } from "@nestjs/common"; 3 | import { IUseCase, Result } from "types-ddd"; 4 | import Dto from "./delete-account.dto"; 5 | 6 | export class DeleteAccountUseCase implements IUseCase> { 7 | 8 | constructor ( 9 | @Inject('UserRepository') 10 | private readonly userRepo: IUserRepository 11 | ){} 12 | 13 | async execute ({ userId: id, password }: Dto) : Promise> { 14 | try { 15 | 16 | const userOrNull = await this.userRepo.findOne({ id }); 17 | 18 | if (!userOrNull) { 19 | return Result.fail('User Not Found', 'NOT_FOUND'); 20 | } 21 | 22 | const user = userOrNull; 23 | 24 | const passwordMatch = user.password.compare(password); 25 | 26 | if (!passwordMatch) { 27 | return Result.fail('Invalid Credentials', 'FORBIDDEN'); 28 | } 29 | 30 | user.deleteAccount(); 31 | 32 | await this.userRepo.delete({ id }); 33 | 34 | return Result.success(); 35 | 36 | } catch (error) { 37 | 38 | return Result.fail( 39 | 'Internal Server Error On Delete Account Use Case', 'INTERNAL_SERVER_ERROR' 40 | ); 41 | 42 | } 43 | }; 44 | } 45 | 46 | export default DeleteAccountUseCase; 47 | -------------------------------------------------------------------------------- /src/modules/user/application/use-cases/get-user-by-id/get-user-by-id.dto.ts: -------------------------------------------------------------------------------- 1 | export interface GetUserByIdDto { 2 | userId: string; 3 | } 4 | 5 | export default GetUserByIdDto; 6 | -------------------------------------------------------------------------------- /src/modules/user/application/use-cases/get-user-by-id/get-user-by-id.spec.ts: -------------------------------------------------------------------------------- 1 | import UserMock from "@modules/user/domain/tests/mock/user.mock"; 2 | import { IUserQueryService } from "@modules/user/infra/services/queries/user-query.interface"; 3 | import userQueryServiceMock from "@modules/user/application/mocks/user-query-service.mock"; 4 | import GetUserByIdUseCase from "./get-user-by-id.use-case"; 5 | 6 | describe('get-user-by-id.use-case', () => { 7 | 8 | const userMock = new UserMock(); 9 | let fakeQueryService: IUserQueryService; 10 | 11 | beforeAll(() => { 12 | fakeQueryService = userQueryServiceMock; 13 | }); 14 | 15 | 16 | it('should get user with success', async (): Promise => { 17 | 18 | const userFound = userMock.model({ id: 'valid_user_id' }); 19 | jest.spyOn(fakeQueryService, 'getUserById').mockResolvedValueOnce(userFound); 20 | 21 | const useCase = new GetUserByIdUseCase(fakeQueryService); 22 | const result = await useCase.execute({ userId: 'valid_user_id' }); 23 | 24 | expect(result.isSuccess).toBeTruthy(); 25 | expect(result.getResult()).toEqual(userFound); 26 | }); 27 | 28 | it('should return fails if user is not found', async (): Promise => { 29 | 30 | jest.spyOn(fakeQueryService, 'getUserById').mockResolvedValueOnce(null); 31 | 32 | const useCase = new GetUserByIdUseCase(fakeQueryService); 33 | const result = await useCase.execute({ userId: 'invalid_user_id' }); 34 | 35 | expect(result.isFailure).toBeTruthy(); 36 | expect(result.statusCodeNumber).toEqual(404); 37 | }); 38 | 39 | it('should return fails with internal server error if throws', async (): Promise => { 40 | 41 | jest.spyOn(fakeQueryService, 'getUserById').mockImplementationOnce( 42 | async () => { 43 | throw new Error("error simulation"); 44 | } 45 | ); 46 | 47 | const useCase = new GetUserByIdUseCase(fakeQueryService); 48 | const result = await useCase.execute({ userId: 'invalid_user_id' }); 49 | 50 | expect(result.isFailure).toBeTruthy(); 51 | expect(result.statusCodeNumber).toEqual(500); 52 | expect(result.error).toEqual('Internal Server Error on Get Authenticated User UseCase'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/modules/user/application/use-cases/get-user-by-id/get-user-by-id.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "@modules/shared"; 2 | import { IUserQueryService } from "@modules/user/infra/services/queries/user-query.interface"; 3 | import { Inject, Injectable } from "@nestjs/common"; 4 | import { IUseCase, Result } from "types-ddd"; 5 | import GetUserByIdDto from "./get-user-by-id.dto"; 6 | 7 | @Injectable() 8 | export class GetUserByIdUseCase implements IUseCase>{ 9 | constructor ( 10 | @Inject('UserQueryService') 11 | private readonly userQueryService: IUserQueryService 12 | ){} 13 | async execute ({ userId }: GetUserByIdDto): Promise>{ 14 | try { 15 | const userFound = await this.userQueryService.getUserById(userId); 16 | 17 | if (!userFound) { 18 | return Result.fail('User Not Found', 'NOT_FOUND'); 19 | } 20 | 21 | return Result.ok(userFound); 22 | } catch (error) { 23 | return Result.fail( 24 | 'Internal Server Error on Get Authenticated User UseCase', 25 | 'INTERNAL_SERVER_ERROR' 26 | ); 27 | } 28 | }; 29 | 30 | } 31 | 32 | export default GetUserByIdUseCase; 33 | -------------------------------------------------------------------------------- /src/modules/user/application/use-cases/signin/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JWTPayload { 2 | token: string 3 | } -------------------------------------------------------------------------------- /src/modules/user/application/use-cases/signin/signin.dto.ts: -------------------------------------------------------------------------------- 1 | export interface SigninDto { 2 | email: string; 3 | password: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/user/application/use-cases/signin/signin.use-case.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@modules/shared'; 2 | import { EmailValueObject, IUseCase, PasswordValueObject, Result, ErrorStatus } from 'types-ddd'; 3 | import { SigninDto } from './signin.dto'; 4 | import { Injectable, Inject } from '@nestjs/common'; 5 | import { JWTPayload } from './jwt-payload.interface'; 6 | import {JwtService} from '@nestjs/jwt'; 7 | import { IUserRepository } from '@modules/user/domain/interfaces/user.repository.interface'; 8 | 9 | 10 | @Injectable() 11 | export class SigninUseCase implements IUseCase>{ 12 | constructor ( 13 | @Inject('UserRepository') 14 | private readonly userRepo: IUserRepository, 15 | 16 | @Inject(JwtService) 17 | private readonly jwtService: JwtService 18 | ) { } 19 | 20 | async execute (dto: SigninDto): Promise> { 21 | 22 | try { 23 | const { password, email } = dto; 24 | const emailOrError = EmailValueObject.create(email); 25 | const passwordOrError = PasswordValueObject.create(password); 26 | 27 | const hasError = Result.combine([emailOrError, passwordOrError]); 28 | 29 | if (hasError.isFailure) { 30 | return Result.fail(hasError.errorValue(), hasError.statusCode as ErrorStatus); 31 | } 32 | 33 | const existsUserForEmail = await this.userRepo.findOne({ email }); 34 | 35 | if (!existsUserForEmail) { 36 | return Result.fail(ErrorMessages.INVALID_CREDENTIALS); 37 | } 38 | 39 | const user = existsUserForEmail; 40 | 41 | const isValidPassword = user.password.compare(password); 42 | 43 | if (!isValidPassword) { 44 | return Result.fail(ErrorMessages.INVALID_CREDENTIALS); 45 | } 46 | 47 | const token = this.jwtService.sign({userId: user.id.toString()}); 48 | 49 | return Result.ok({token}); 50 | 51 | } catch (error) { 52 | 53 | return Result.fail('Internal Server Error on Signin Use Case', 'INTERNAL_SERVER_ERROR'); 54 | 55 | } 56 | 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/modules/user/application/use-cases/signup/signup.dto.ts: -------------------------------------------------------------------------------- 1 | export interface UserAgent { 2 | name: string; 3 | version: string; 4 | os: string; 5 | type: string; 6 | } 7 | 8 | export interface Term { 9 | ip: string; 10 | acceptedAt: Date; 11 | userAgent: UserAgent; 12 | } 13 | 14 | export interface SignUpDto { 15 | email: string; 16 | password: string; 17 | term: Term; 18 | acceptedTerms: boolean; 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/user/domain/README.md: -------------------------------------------------------------------------------- 1 | # User - Aggregate 2 | 3 | --- 4 | 5 | Este agregado identifica cada indivíduo cadastrado na plataforma, 6 | bem como controla o acesso e define o proprietário de cada registro. 7 | 8 | ```json 9 | { 10 | "id": "uuid", 11 | "email": "valid_email@domain.com", 12 | "password": "valid_password", 13 | "createdAt": "2021-01-01 10:00:00", 14 | "updatedAt": "2021-01-01 10:00:00", 15 | "deletedAt": "", 16 | "terms": [ 17 | { 18 | "ip": "127.0.0.1", 19 | "acceptedAt": "2021-01-01 10:00:00", 20 | "userAgent": { 21 | "name": "firefox", 22 | "version": "86.0", 23 | "os": "windows", 24 | "type": "browser", 25 | } 26 | }, 27 | ] 28 | } 29 | ``` 30 | 31 | - email: Value Object - Ok 32 | - password: Value Object - Ok 33 | - terms: Value Object - Ok 34 | - ip: Value Object - Ok 35 | 36 | ### Fluxos 37 | 38 | #### Excluir uma conta de usuário 39 | 40 | imagem 41 | -------------------------------------------------------------------------------- /src/modules/user/domain/events/delete-user-account.event.ts: -------------------------------------------------------------------------------- 1 | import { IDomainEvent, UniqueEntityID } from "types-ddd"; 2 | import UserAggregate from "@modules/user/domain/user.aggregate"; 3 | 4 | export class UserAccountDeletedEvent implements IDomainEvent { 5 | public dateTimeOccurred: Date; 6 | public user: UserAggregate; 7 | 8 | constructor (user: UserAggregate) { 9 | this.user = user; 10 | this.dateTimeOccurred = new Date(); 11 | } 12 | 13 | getAggregateId (): UniqueEntityID { 14 | return this.user.id.value; 15 | } 16 | 17 | } 18 | 19 | export default UserAccountDeletedEvent; 20 | -------------------------------------------------------------------------------- /src/modules/user/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.aggregate'; 2 | -------------------------------------------------------------------------------- /src/modules/user/domain/interfaces/user.repository.interface.ts: -------------------------------------------------------------------------------- 1 | import { IBaseRepository } from 'types-ddd'; 2 | import { UserAggregate } from '../user.aggregate'; 3 | import { IUser } from '@shared/index'; 4 | 5 | export type IUserRepository = IBaseRepository; 6 | -------------------------------------------------------------------------------- /src/modules/user/domain/ip.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { Result, ValueObject } from 'types-ddd'; 3 | const validateIpRegex = /\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/; 4 | 5 | export interface IpValueObjectProps { 6 | value: string; 7 | } 8 | 9 | export class IpValueObject extends ValueObject { 10 | private constructor (props: IpValueObjectProps) { 11 | super(props); 12 | } 13 | 14 | get value (): string { 15 | return this.props.value; 16 | } 17 | 18 | public static isValidValue (ip: string): boolean { 19 | return validateIpRegex.test(ip); 20 | } 21 | 22 | public static create (ip: string): Result { 23 | 24 | const isValidIp = IpValueObject.isValidValue(ip); 25 | 26 | if (!isValidIp) { 27 | return Result.fail(ErrorMessages.INVALID_IP); 28 | } 29 | return Result.ok(new IpValueObject({ value: ip })); 30 | } 31 | } 32 | 33 | export default IpValueObject; 34 | -------------------------------------------------------------------------------- /src/modules/user/domain/subscriptions/after-delete-user-account.subscription.ts: -------------------------------------------------------------------------------- 1 | import { IDomainService } from "@modules/shared"; 2 | import DeleteBudgetBoxByUserIdDomainService from "@modules/shared/domain/delete-budget-box-by-user-id.domain-service"; 3 | import DeleteTransactionsByUserIdDomainService from "@modules/shared/domain/delete-transactions-by-user-id.domain-service"; 4 | import { Inject } from "@nestjs/common"; 5 | import { DomainEvents, IHandle, Logger, Result } from "types-ddd"; 6 | import UserAccountDeletedEvent from "../events/delete-user-account.event"; 7 | 8 | interface Dto { 9 | userId: string; 10 | } 11 | export class AfterDeleteUserAccount implements IHandle{ 12 | 13 | constructor ( 14 | @Inject(DeleteTransactionsByUserIdDomainService) 15 | private readonly deleteTransactions: IDomainService>, 16 | 17 | @Inject(DeleteBudgetBoxByUserIdDomainService) 18 | private readonly deleteBoxes: IDomainService> 19 | ) { 20 | this.setupSubscriptions(); 21 | } 22 | 23 | setupSubscriptions (): void { 24 | DomainEvents.register( 25 | (event) => this.dispatch(Object.assign(event)), 26 | UserAccountDeletedEvent.name 27 | ); 28 | } 29 | 30 | async dispatch (event: UserAccountDeletedEvent): Promise { 31 | 32 | const userId = event.user.id.uid; 33 | 34 | const transactionResult = await this.deleteTransactions.execute({ userId }); 35 | const budgetBoxResult = await this.deleteBoxes.execute({ userId }); 36 | 37 | const allDataDeleted = transactionResult.isSuccess && budgetBoxResult.isSuccess; 38 | 39 | if (!allDataDeleted) { 40 | return Logger.error('Some user info not deleted'); 41 | } 42 | 43 | return Logger.info('Success to delete user'); 44 | } 45 | } 46 | 47 | export default AfterDeleteUserAccount; 48 | -------------------------------------------------------------------------------- /src/modules/user/domain/subscriptions/after-delete-user-subscription.spec.ts: -------------------------------------------------------------------------------- 1 | import { IDomainService } from "@modules/shared"; 2 | import { Logger, Result } from "types-ddd"; 3 | import UserAccountDeletedEvent from "../events/delete-user-account.event"; 4 | import UserMock from "../tests/mock/user.mock"; 5 | import AfterDeleteUserAccount from "./after-delete-user-account.subscription"; 6 | 7 | describe('after-delete-user.subscription', () => { 8 | 9 | let service: IDomainService<{ userId: string }, Result> = { 10 | execute: jest.fn() 11 | }; 12 | 13 | const useMock = new UserMock(); 14 | 15 | beforeEach(() => { 16 | service = { 17 | execute: jest.fn() 18 | }; 19 | }); 20 | 21 | it('should dispatch with success', async () => { 22 | 23 | const user = useMock.domain().getResult(); 24 | 25 | jest.spyOn(service, 'execute').mockResolvedValue(Result.ok(true)); 26 | const serviceSpy = jest.spyOn(service, 'execute'); 27 | const loggerSpy = jest.spyOn(Logger, 'info'); 28 | 29 | const event = new AfterDeleteUserAccount(service, service); 30 | 31 | await event.dispatch(new UserAccountDeletedEvent(user)); 32 | 33 | expect(serviceSpy).toHaveBeenCalled(); 34 | expect(loggerSpy).toHaveBeenCalled(); 35 | }); 36 | 37 | it('should dispatch with error', async () => { 38 | 39 | const user = useMock.domain().getResult(); 40 | 41 | jest.spyOn(service, 'execute').mockResolvedValue(Result.fail('error')); 42 | const serviceSpy = jest.spyOn(service, 'execute'); 43 | const loggerSpy = jest.spyOn(Logger, 'error'); 44 | 45 | const event = new AfterDeleteUserAccount(service, service); 46 | 47 | await event.dispatch(new UserAccountDeletedEvent(user)); 48 | 49 | expect(serviceSpy).toHaveBeenCalled(); 50 | expect(loggerSpy).toHaveBeenCalled(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/modules/user/domain/term.value-object.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject, Result, DateValueObject } from 'types-ddd'; 2 | import { IpValueObject } from './ip.value-object'; 3 | 4 | export interface IUserAgent { 5 | name: string; 6 | version: string; 7 | os: string; 8 | type: string; 9 | } 10 | 11 | export interface TermValueObjectProps { 12 | ip: IpValueObject; 13 | acceptedAt: DateValueObject; 14 | userAgent: IUserAgent; 15 | isAccepted: boolean; 16 | } 17 | 18 | export class TermValueObject extends ValueObject { 19 | private constructor (props: TermValueObjectProps) { 20 | super(props); 21 | } 22 | 23 | get ip (): IpValueObject { 24 | return this.props.ip; 25 | } 26 | 27 | get acceptedAt (): DateValueObject { 28 | return this.props.acceptedAt; 29 | } 30 | 31 | get userAgent (): IUserAgent { 32 | return this.props.userAgent; 33 | } 34 | 35 | public static create (props: TermValueObjectProps): Result { 36 | if (!props.isAccepted) { 37 | return Result.fail('Terms must be accepted'); 38 | } 39 | return Result.ok(new TermValueObject(props)); 40 | } 41 | } 42 | 43 | export default TermValueObject; 44 | -------------------------------------------------------------------------------- /src/modules/user/domain/tests/__snapshots__/user.aggregate.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`user.aggregate should get valid values 1`] = ` 4 | Array [ 5 | TermValueObject { 6 | "props": Object { 7 | "acceptedAt": DateValueObject { 8 | "ONE_DAY": 86400000, 9 | "ONE_HOUR": 3600000, 10 | "ONE_MINUTE": 60000, 11 | "ONE_MONTH": 2678400000, 12 | "ONE_WEEK": 604800000, 13 | "ONE_YEAR": 31622400000, 14 | "props": Object { 15 | "value": 2022-01-01T00:00:00.000Z, 16 | }, 17 | }, 18 | "ip": IpValueObject { 19 | "props": Object { 20 | "value": "127.0.0.1", 21 | }, 22 | }, 23 | "isAccepted": true, 24 | "userAgent": Object { 25 | "name": "firefox", 26 | "os": "linux", 27 | "type": "browser", 28 | "version": "86.0.1", 29 | }, 30 | }, 31 | }, 32 | ] 33 | `; 34 | -------------------------------------------------------------------------------- /src/modules/user/domain/tests/ip.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from '@shared/index'; 2 | import { IpValueObject } from '../ip.value-object'; 3 | 4 | describe('ip.value-object', () => { 5 | it('should create a valid ip', () => { 6 | const ip = IpValueObject.create('123.123.123.123'); 7 | 8 | expect(ip.isSuccess).toBe(true); 9 | expect(ip.getResult().value).toBe('123.123.123.123'); 10 | }); 11 | 12 | it('should fail if provide an invalid ip', () => { 13 | const ip = IpValueObject.create('invalid_ip'); 14 | 15 | expect(ip.isFailure).toBe(true); 16 | expect(ip.error).toBe(ErrorMessages.INVALID_IP); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/user/domain/tests/mock/user-mock.spec.ts: -------------------------------------------------------------------------------- 1 | import UserMock from "./user.mock"; 2 | 3 | describe('user.mock', () => { 4 | 5 | const userMock = new UserMock(); 6 | 7 | it('should create a valid user model mock', () => { 8 | const model = userMock.model(); 9 | 10 | expect(model).toBeDefined(); 11 | }); 12 | 13 | it('should create a valid user model mock with provided id', () => { 14 | const model = userMock.model({ id: 'provided_id' }); 15 | 16 | expect(model.id).toBe('provided_id'); 17 | expect(model).toMatchSnapshot(); 18 | }); 19 | 20 | it('should create a valid user aggregate mock with provided id', () => { 21 | const domain = userMock.domain({ id: 'provided_id' }); 22 | 23 | 24 | expect(domain.isSuccess).toBeTruthy(); 25 | expect(domain.getResult().id.uid).toBe('provided_id'); 26 | expect(domain).toMatchSnapshot(); 27 | }); 28 | 29 | it('should create a valid user aggregate mock', () => { 30 | const domain = userMock.domain(); 31 | 32 | expect(domain.isSuccess).toBeTruthy(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/modules/user/domain/tests/term.value-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { DateValueObject } from 'types-ddd'; 2 | import { IpValueObject } from '../ip.value-object'; 3 | import { TermValueObject } from '../term.value-object'; 4 | 5 | describe('term.value-object', () => { 6 | interface fakeTermProps { 7 | ip: string; 8 | acceptedAt: Date; 9 | userAgent: { 10 | name: string; 11 | version: string; 12 | os: string; 13 | type: string; 14 | }; 15 | } 16 | 17 | const makeFakeTermProps = (props?: Partial): fakeTermProps => { 18 | return { 19 | userAgent: { 20 | name: props?.userAgent?.name ?? 'firefox', 21 | os: props?.userAgent?.os ?? 'LINUX', 22 | type: props?.userAgent?.type ?? 'browser', 23 | version: props?.userAgent?.version ?? '86.0.0', 24 | }, 25 | acceptedAt: props?.acceptedAt ?? new Date('2020-01-01 00:00:00'), 26 | ip: props?.ip ?? '123.123.123.123', 27 | }; 28 | }; 29 | 30 | it('should create a valid term', () => { 31 | const props = makeFakeTermProps(); 32 | const term = TermValueObject.create({ 33 | acceptedAt: DateValueObject.create(props.acceptedAt).getResult(), 34 | ip: IpValueObject.create(props.ip).getResult(), 35 | userAgent: props.userAgent, 36 | isAccepted: true 37 | }); 38 | 39 | expect(term.isSuccess).toBe(true); 40 | expect(term.getResult()).toBe(term.getResult()); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/modules/user/domain/tests/user.aggregate.spec.ts: -------------------------------------------------------------------------------- 1 | import UserMock from './mock/user.mock'; 2 | 3 | describe('user.aggregate', () => { 4 | 5 | const userMock = new UserMock(); 6 | 7 | it('should create a valid user', () => { 8 | const user = userMock.domain(); 9 | 10 | expect(user.isSuccess).toBe(true); 11 | }); 12 | 13 | it('should get valid values', () => { 14 | const user = userMock.domain(); 15 | 16 | const userResult = user.getResult(); 17 | 18 | expect(userResult.id).toBeDefined(); 19 | expect(userResult.createdAt).toBeDefined(); 20 | expect(userResult.email.value).toBe('valid_email@domain.com'); 21 | expect(userResult.isDeleted).toBeFalsy(); 22 | expect(userResult.password.value).toBe('valid_password'); 23 | expect(userResult.terms).toMatchSnapshot(); 24 | }); 25 | 26 | it('should create a valid user with provided id', () => { 27 | const user = userMock.domain({ id: 'valid_id' }); 28 | 29 | expect(user.getResult().id.toValue()).toBe('valid_id'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/modules/user/domain/user.aggregate.ts: -------------------------------------------------------------------------------- 1 | import { TermValueObject } from './term.value-object'; 2 | import { 3 | AggregateRoot, 4 | BaseDomainEntity, 5 | EmailValueObject, 6 | PasswordValueObject, 7 | Result 8 | } from 'types-ddd'; 9 | import UserAccountDeletedEvent from './events/delete-user-account.event'; 10 | 11 | export interface UserAggregateProps extends BaseDomainEntity { 12 | email: EmailValueObject; 13 | password: PasswordValueObject; 14 | terms: TermValueObject[]; 15 | } 16 | 17 | /** 18 | * @var email: `EmailValueObject` 19 | * @var password: `PasswordValueObject` 20 | * @var terms: `TermValueObject[]` 21 | */ 22 | export class UserAggregate extends AggregateRoot { 23 | private constructor (props: UserAggregateProps) { 24 | super(props, UserAggregate.name); 25 | } 26 | 27 | get email (): EmailValueObject { 28 | return this.props.email; 29 | } 30 | 31 | get password (): PasswordValueObject { 32 | return this.props.password; 33 | } 34 | 35 | get terms (): TermValueObject[] { 36 | return this.props.terms; 37 | } 38 | 39 | get deletedAt (): Date | undefined { 40 | return this.props.deletedAt; 41 | } 42 | 43 | deleteAccount (): void { 44 | this.props.isDeleted = true; 45 | this.props.deletedAt = new Date(); 46 | this.addDomainEvent(new UserAccountDeletedEvent(this)); 47 | } 48 | 49 | public static create ( 50 | props: UserAggregateProps 51 | ): Result { 52 | return Result.ok(new UserAggregate(props)); 53 | } 54 | } 55 | 56 | export default UserAggregate; 57 | -------------------------------------------------------------------------------- /src/modules/user/infra/entities/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { IUser, ITerm } from '@shared/index'; 2 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 3 | import { Document } from 'mongoose'; 4 | import { DomainEvents, DomainId } from 'types-ddd'; 5 | 6 | export type UserDocument = User & Document; 7 | @Schema({ autoCreate: true, timestamps: true, autoIndex: true }) 8 | export class User implements IUser { 9 | 10 | @Prop({ immutable: true, required: true, type: String, index: true, unique: true }) 11 | readonly id!: string; 12 | 13 | @Prop({ required: true, index: true, type: String, unique: true }) 14 | email!: string; 15 | 16 | @Prop({ type: String, required: true }) 17 | password!: string; 18 | 19 | @Prop({ type: Array, required: true }) 20 | terms!: Array; 21 | 22 | @Prop({ type: Date, required: true, default: new Date() }) 23 | createdAt!: Date; 24 | 25 | @Prop({ type: Date, required: true, default: new Date() }) 26 | updatedAt!: Date; 27 | 28 | @Prop({ type: Boolean, default: false }) 29 | isDeleted?: boolean; 30 | } 31 | 32 | export const UserSchema = SchemaFactory.createForClass(User); 33 | 34 | 35 | // execute hooks on delete user account 36 | UserSchema.post('remove', function (doc: IUser){ 37 | const id = DomainId.create(doc.id); 38 | DomainEvents.dispatchEventsForAggregate(id.value); 39 | }); 40 | -------------------------------------------------------------------------------- /src/modules/user/infra/inputs/delete-account.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class DeleteUserAccountInput { 5 | 6 | @Field(() => String) 7 | password!: string; 8 | 9 | } 10 | 11 | export default DeleteUserAccountInput; 12 | -------------------------------------------------------------------------------- /src/modules/user/infra/inputs/signin.input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class SigninInput { 5 | 6 | @Field(() => String) 7 | email!: string; 8 | 9 | @Field(() => String) 10 | password!: string; 11 | 12 | } 13 | 14 | export default SigninInput; 15 | -------------------------------------------------------------------------------- /src/modules/user/infra/inputs/signup.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class SignupInput { 5 | @Field(() => String) 6 | email!: string; 7 | 8 | @Field(() => String) 9 | password!: string; 10 | 11 | @Field(() => Boolean) 12 | acceptedTerms!: boolean; 13 | } 14 | 15 | export default SignupInput; 16 | -------------------------------------------------------------------------------- /src/modules/user/infra/repo/__snapshots__/user.mapper.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`user.mapper should convert object from domain to persistence 1`] = ` 4 | Object { 5 | "createdAt": 2020-01-01T00:00:00.000Z, 6 | "deletedAt": undefined, 7 | "email": "valid_mail@domain.com", 8 | "id": "valid_id", 9 | "isDeleted": false, 10 | "password": "valid_password", 11 | "terms": Array [ 12 | Object { 13 | "acceptedAt": 2020-01-01T00:00:00.000Z, 14 | "ip": "45.192.110.42", 15 | "isAccepted": true, 16 | "userAgent": Object { 17 | "name": "firefox", 18 | "os": "LINUX", 19 | "type": "browser", 20 | "version": "80.0.1", 21 | }, 22 | }, 23 | ], 24 | "updatedAt": 2020-01-01T00:00:00.000Z, 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /src/modules/user/infra/repo/user.mapper.ts: -------------------------------------------------------------------------------- 1 | import { UserAggregate } from '@modules/user/domain'; 2 | import { IpValueObject } from '@modules/user/domain/ip.value-object'; 3 | import { TermValueObject } from '@modules/user/domain/term.value-object'; 4 | import { TMapper, DomainId, EmailValueObject, PasswordValueObject, DateValueObject, Result } from 'types-ddd'; 5 | import { User } from '../entities/user.schema'; 6 | 7 | export class UserToDomainMapper implements TMapper { 8 | map (target: User): Result { 9 | return UserAggregate.create( 10 | { 11 | ID: DomainId.create(target.id), 12 | email: EmailValueObject.create(target.email).getResult(), 13 | password: PasswordValueObject.create(target.password).getResult(), 14 | terms: target.terms.map((term) => 15 | TermValueObject.create({ 16 | acceptedAt: DateValueObject.create(term.acceptedAt).getResult(), 17 | ip: IpValueObject.create(term.ip).getResult(), 18 | isAccepted: true, 19 | userAgent: { 20 | name: term.userAgent.name, 21 | os: term.userAgent.os, 22 | type: term.userAgent.type, 23 | version: term.userAgent.version, 24 | }, 25 | }).getResult(), 26 | ), 27 | createdAt: target.createdAt, 28 | updatedAt: target.updatedAt, 29 | } 30 | ); 31 | } 32 | } 33 | export default UserToDomainMapper; 34 | -------------------------------------------------------------------------------- /src/modules/user/infra/repo/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from 'types-ddd'; 2 | import { UserToDomainMapper } from './user.mapper'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { User, UserDocument } from '../entities/user.schema'; 5 | import { Model } from 'mongoose'; 6 | import { Inject } from '@nestjs/common'; 7 | import { UserAggregate } from '@modules/user/domain'; 8 | import { IUserRepository } from '@modules/user/domain/interfaces/user.repository.interface'; 9 | 10 | export class UserRepository implements IUserRepository { 11 | 12 | constructor ( 13 | @Inject(UserToDomainMapper) private readonly mapper: UserToDomainMapper, 14 | @InjectModel(User.name) private readonly conn: Model, 15 | ) { } 16 | 17 | async find (filter: Filter): Promise { 18 | return await this.conn.find({ ...filter }); 19 | } 20 | 21 | async findOne (filter: Filter): Promise { 22 | const foundUser = await this.conn.findOne(filter); 23 | 24 | if (!foundUser) { 25 | return null; 26 | } 27 | 28 | return this.mapper.map(foundUser).getResult(); 29 | } 30 | 31 | async delete (filter: Filter): Promise { 32 | const document = await this.conn.findOne({ ...filter }); 33 | 34 | if (!document) return; 35 | 36 | await document.remove(); 37 | } 38 | 39 | async exists (filter: Filter): Promise { 40 | const result = await this.conn.exists(filter); 41 | return !!result; 42 | } 43 | 44 | async save (target: UserAggregate): Promise { 45 | const schema = target.toObject(); 46 | const user = new this.conn(schema); 47 | await user.save(); 48 | } 49 | } 50 | 51 | export default UserRepository; 52 | -------------------------------------------------------------------------------- /src/modules/user/infra/services/decorators/get-ip.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | 4 | export const GetUserIp = createParamDecorator((_data: any, ctx: ExecutionContext): string => { 5 | 6 | const context = GqlExecutionContext.create(ctx).getContext(); 7 | 8 | const connection = context.req.connection; 9 | const headers = context.req.headers; 10 | 11 | const remoteIp = connection.remoteAddress; 12 | const forwardedIp = headers['x-forwarded-for']; 13 | 14 | const initialIp: string = remoteIp ?? forwardedIp; 15 | 16 | let ip = initialIp.replace(/[:]|[\s]|[f]/g, ''); 17 | 18 | if (ip === '1') { 19 | ip = '127.0.0.1'; 20 | } 21 | 22 | return ip; 23 | }); 24 | -------------------------------------------------------------------------------- /src/modules/user/infra/services/decorators/get-user-agent.decorator.ts: -------------------------------------------------------------------------------- 1 | import { UserAgentType } from '@modules/user/infra/types/user-agent.type'; 2 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | import { UAParser } from 'ua-parser-js'; 5 | 6 | export const GetUserAgent = createParamDecorator((_data: any, ctx: ExecutionContext): UserAgentType => { 7 | 8 | const context = GqlExecutionContext.create(ctx).getContext(); 9 | const headers = context.req.headers; 10 | const uaParser = new UAParser(); 11 | 12 | const userAgent = uaParser.setUA(headers['user-agent']).getResult(); 13 | 14 | return { 15 | name: userAgent?.browser?.name ?? 'undefined', 16 | os: userAgent?.os?.name?.toUpperCase() as any ?? 'undefined', 17 | type: userAgent?.device?.type ?? 'browser', 18 | version: userAgent?.browser?.version?.slice(0, 4) ?? '89.0' 19 | }; 20 | 21 | }); -------------------------------------------------------------------------------- /src/modules/user/infra/services/decorators/get-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | import { Request } from 'express'; 4 | 5 | interface IRequest extends Request { 6 | user?: { userId?: string } 7 | } 8 | 9 | export const GetUserId = createParamDecorator((_data: any, ctx: ExecutionContext): string => { 10 | 11 | const context = GqlExecutionContext.create(ctx).getContext(); 12 | 13 | const req: IRequest = context.req; 14 | 15 | const userId = String(req.user?.userId); 16 | 17 | return userId; 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/user/infra/services/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from "@nestjs/common"; 2 | import { GqlExecutionContext } from "@nestjs/graphql"; 3 | import { AuthGuard } from "@nestjs/passport"; 4 | 5 | export class JwtAuthGuard extends AuthGuard('jwt'){ 6 | getRequest (context: ExecutionContext) { 7 | const ctx = GqlExecutionContext.create(context); 8 | return ctx.getContext().req; 9 | } 10 | } -------------------------------------------------------------------------------- /src/modules/user/infra/services/queries/user-query.interface.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "@modules/shared"; 2 | 3 | export interface IUserQueryService { 4 | getUserById(userId: string): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/user/infra/services/queries/user-query.service.ts: -------------------------------------------------------------------------------- 1 | import { IUserQueryService } from "@modules/user/infra/services/queries/user-query.interface"; 2 | import { User, UserDocument } from "@modules/user/infra/entities/user.schema"; 3 | import { Injectable } from "@nestjs/common"; 4 | import { InjectModel } from "@nestjs/mongoose"; 5 | import { Model } from "mongoose"; 6 | 7 | @Injectable() 8 | export class UserQueryService implements IUserQueryService{ 9 | 10 | constructor ( 11 | @InjectModel(User.name) private readonly conn: Model, 12 | ) { } 13 | 14 | async getUserById (userId: string): Promise { 15 | const userFound = this.conn.findOne( 16 | { id: userId }, 17 | { password: false, _id: false, __v: false } 18 | ); 19 | 20 | if (!userFound) return null; 21 | 22 | return userFound; 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /src/modules/user/infra/services/strategies/Jwt-decoded.payload.ts: -------------------------------------------------------------------------------- 1 | export interface JwtDecodedPayload { 2 | userId: string 3 | } -------------------------------------------------------------------------------- /src/modules/user/infra/services/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { JWT_SECRET } from "@config/env"; 2 | import { User, UserDocument } from "@modules/user/infra/entities/user.schema"; 3 | import { UnauthorizedException } from "@nestjs/common"; 4 | import { InjectModel } from "@nestjs/mongoose"; 5 | import { PassportStrategy } from "@nestjs/passport"; 6 | import { Model } from "mongoose"; 7 | import { ExtractJwt, Strategy } from "passport-jwt"; 8 | import { JwtDecodedPayload } from "./Jwt-decoded.payload"; 9 | import { ErrorMessages } from "@modules/shared"; 10 | 11 | export class JwtStrategy extends PassportStrategy(Strategy) { 12 | constructor ( 13 | @InjectModel(User.name) 14 | private readonly conn: Model 15 | ) { 16 | super({ 17 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 18 | ignoreExpiration: false, 19 | secretOrKey: JWT_SECRET 20 | }); 21 | } 22 | 23 | async validate (payload: JwtDecodedPayload): Promise { 24 | 25 | const id = payload.userId; 26 | 27 | const userExist = await this.conn.findOne({ id }); 28 | 29 | if (!userExist) { 30 | throw new UnauthorizedException(ErrorMessages.INVALID_CREDENTIALS); 31 | } 32 | 33 | return { userId: id }; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/user/infra/tests/user.mutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "graphql-request"; 2 | 3 | export const RESULT = gql``; 4 | 5 | export const TOKEN_RESULT = gql` 6 | fragment Token on JwtPayloadType { 7 | token 8 | } 9 | `; 10 | 11 | export const SIGNUP_MUTATION = gql` 12 | mutation($SignupInput: SignupInput!) { 13 | signup(SignupInput: $SignupInput) 14 | }${RESULT} 15 | `; 16 | 17 | export const SIGNIN_MUTATION = gql` 18 | mutation($SigninInput: SigninInput!) { 19 | signin(SigninInput: $SigninInput){ 20 | ...Token 21 | } 22 | }${TOKEN_RESULT} 23 | `; 24 | -------------------------------------------------------------------------------- /src/modules/user/infra/tests/user.query.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "graphql-request"; 2 | 3 | export const AUTH_QUERY = gql` 4 | query { 5 | whoAmI { 6 | id 7 | email 8 | terms { 9 | ip 10 | } 11 | } 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /src/modules/user/infra/types/jwt-payload.type.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from "@nestjs/graphql"; 2 | 3 | @ObjectType() 4 | export class JwtPayloadType { 5 | 6 | @Field(() => String) 7 | token!: string; 8 | 9 | } 10 | 11 | export default JwtPayloadType; 12 | -------------------------------------------------------------------------------- /src/modules/user/infra/types/term.type.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from "@nestjs/graphql"; 2 | import { UserAgentType } from "./user-agent.type"; 3 | 4 | @ObjectType() 5 | export class TermType { 6 | @Field(() => String) 7 | ip!: string; 8 | 9 | @Field(() => String) 10 | acceptedAt!: Date; 11 | 12 | @Field(() => UserAgentType) 13 | userAgent!: UserAgentType; 14 | } 15 | 16 | export default TermType; 17 | -------------------------------------------------------------------------------- /src/modules/user/infra/types/user-agent.type.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from "@nestjs/graphql"; 2 | 3 | @ObjectType() 4 | export class UserAgentType { 5 | @Field(() => String) 6 | name!: string; 7 | 8 | @Field(() => String) 9 | version!: string; 10 | 11 | @Field(() => String) 12 | os!: string; 13 | 14 | @Field(() => String) 15 | type!: string; 16 | } 17 | 18 | export default UserAgentType; 19 | -------------------------------------------------------------------------------- /src/modules/user/infra/types/user.type.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from "@nestjs/graphql"; 2 | import { TermType } from "./term.type"; 3 | 4 | @ObjectType() 5 | export class UserType { 6 | 7 | @Field(() => ID) 8 | id!: string; 9 | 10 | @Field(() => [TermType], { nullable: true }) 11 | terms!: TermType[]; 12 | 13 | @Field(() => String) 14 | email!: string; 15 | } 16 | 17 | export default UserType; 18 | -------------------------------------------------------------------------------- /src/modules/user/infra/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import GetUserByIdUseCase from "@modules/user/application/use-cases/get-user-by-id/get-user-by-id.use-case"; 3 | import { SignUpUseCase } from '@modules/user/application/use-cases/signup/signup.use-case'; 4 | import { UserResolver } from "./resolver/user.resolver"; 5 | import { UserService } from './user.service'; 6 | import { UserRepository } from './repo/user.repository'; 7 | import { UserToDomainMapper } from './repo/user.mapper'; 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { UserQueryService } from "@modules/user/infra/services/queries/user-query.service"; 10 | import { SigninUseCase } from "@modules/user/application/use-cases/signin/signin.use-case"; 11 | import { JwtStrategy } from "@modules/user/infra/services/strategies/jwt.strategy"; 12 | import { User, UserSchema } from '@modules/user/infra/entities/user.schema'; 13 | import { PassportModule } from "@nestjs/passport"; 14 | import { JwtModule } from "@nestjs/jwt"; 15 | import { JWT_SECRET } from "@config/env"; 16 | import DeleteAccountUseCase from "@modules/user/application/use-cases/delete-account/delete-account.use-case"; 17 | import AfterDeleteUserAccount from "@modules/user/domain/subscriptions/after-delete-user-account.subscription"; 18 | import { SharedModule } from "@modules/shared"; 19 | 20 | @Module({ 21 | imports: [ 22 | SharedModule, 23 | MongooseModule.forFeature([ 24 | { name: User.name, schema: UserSchema } 25 | ]), 26 | PassportModule.register({ 27 | defaultStrategy: 'jwt' 28 | }), 29 | JwtModule.register({ 30 | secret: JWT_SECRET, 31 | signOptions: { 32 | expiresIn: '8h' 33 | } 34 | }) 35 | ], 36 | providers: [ 37 | UserToDomainMapper, 38 | { 39 | provide: 'UserRepository', 40 | useClass: UserRepository 41 | }, 42 | { 43 | provide: 'UserQueryService', 44 | useClass: UserQueryService 45 | }, 46 | SignUpUseCase, 47 | SigninUseCase, 48 | GetUserByIdUseCase, 49 | UserService, 50 | UserResolver, 51 | JwtStrategy, 52 | PassportModule, 53 | JwtModule, 54 | UserQueryService, 55 | DeleteAccountUseCase, 56 | AfterDeleteUserAccount 57 | ], 58 | exports: [PassportModule, JwtModule] 59 | }) 60 | export class UserModule { } 61 | -------------------------------------------------------------------------------- /src/modules/user/infra/user.service.ts: -------------------------------------------------------------------------------- 1 | import { JWTPayload } from '@modules/user/application/use-cases/signin/jwt-payload.interface'; 2 | import { SigninDto } from '@modules/user/application/use-cases/signin/signin.dto'; 3 | import { SigninUseCase } from '@modules/user/application/use-cases/signin/signin.use-case'; 4 | import { SignUpDto } from '@modules/user/application/use-cases/signup/signup.dto'; 5 | import { SignUpUseCase } from '@modules/user/application/use-cases/signup/signup.use-case'; 6 | import { CheckResultInterceptor } from '@utils/check-result.interceptor'; 7 | import { Inject, Injectable } from '@nestjs/common'; 8 | import GetUserByIdUseCase from '@modules/user/application/use-cases/get-user-by-id/get-user-by-id.use-case'; 9 | import { IUser } from '@modules/shared'; 10 | import DeleteAccountUseCase from '../application/use-cases/delete-account/delete-account.use-case'; 11 | import DeleteAccountDto from '../application/use-cases/delete-account/delete-account.dto'; 12 | 13 | @Injectable() 14 | export class UserService { 15 | 16 | constructor ( 17 | @Inject(SignUpUseCase) 18 | private readonly signupUseCase: SignUpUseCase, 19 | 20 | @Inject(SigninUseCase) 21 | private readonly signinUseCase: SigninUseCase, 22 | 23 | @Inject(GetUserByIdUseCase) 24 | private readonly getUserByIdUseCase: GetUserByIdUseCase, 25 | 26 | @Inject(DeleteAccountUseCase) 27 | private readonly deleteAccountUseCase: DeleteAccountUseCase 28 | ) { } 29 | 30 | async getAuthUser (userId: string): Promise { 31 | const result = await this.getUserByIdUseCase.execute({ userId }); 32 | CheckResultInterceptor(result); 33 | return result.getResult(); 34 | } 35 | 36 | async signup (dto: SignUpDto): Promise { 37 | CheckResultInterceptor(await this.signupUseCase.execute(dto)); 38 | } 39 | 40 | async signin (dto: SigninDto): Promise { 41 | const result = CheckResultInterceptor(await this.signinUseCase.execute(dto)); 42 | return result.getResult(); 43 | } 44 | 45 | async deleteAccount (dto: DeleteAccountDto): Promise { 46 | const result = await this.deleteAccountUseCase.execute(dto); 47 | CheckResultInterceptor(result); 48 | } 49 | } 50 | 51 | export default UserService; 52 | -------------------------------------------------------------------------------- /src/utils/check-result.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConflictException, 3 | ForbiddenException, 4 | InternalServerErrorException, 5 | NotFoundException, 6 | PreconditionFailedException, 7 | UnauthorizedException, 8 | UnprocessableEntityException, 9 | BadRequestException 10 | } from '@nestjs/common'; 11 | import { Result } from "types-ddd"; 12 | 13 | export const CheckResultInterceptor = (result: Result): Result => { 14 | 15 | if (result.isFailure) { 16 | switch(result.isFailure) { 17 | case result.statusCode === 'FORBIDDEN': 18 | throw new ForbiddenException(result.errorValue()); 19 | case result.statusCode === 'CONFLICT': 20 | throw new ConflictException(result.errorValue()); 21 | case result.statusCode === 'NOT_FOUND': 22 | throw new NotFoundException(result.errorValue()); 23 | case result.statusCode === 'PRECONDITION_FAILED': 24 | throw new PreconditionFailedException(result.errorValue()); 25 | case result.statusCode === 'UNAUTHORIZED': 26 | throw new UnauthorizedException(result.errorValue()); 27 | case result.statusCode === 'UNPROCESSABLE_ENTITY': 28 | throw new UnprocessableEntityException(result.errorValue()); 29 | case result.statusCode === 'NOT_MODIFIED': 30 | throw new BadRequestException(result.errorValue()); 31 | case result.statusCode === 'USE_PROXY': 32 | throw new BadRequestException(result.errorValue()); 33 | default: 34 | throw new InternalServerErrorException(result.errorValue()); 35 | } 36 | } 37 | 38 | return result; 39 | }; 40 | 41 | export default CheckResultInterceptor; 42 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "**/*spec.ts", 8 | "**/*test.ts" 9 | ] 10 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "rootDir": "src", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "ES2019", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "incremental": false, 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "skipDefaultLibCheck": true, 18 | "skipLibCheck": true, 19 | "baseUrl": "src", 20 | "paths": { 21 | "@modules/*": [ 22 | "modules/*" 23 | ], 24 | "@shared/*": [ 25 | "modules/shared/*" 26 | ], 27 | "@shared-common/*": [ 28 | "modules/shared/common/*" 29 | ], 30 | "@config/*": [ 31 | "config/*" 32 | ], 33 | "@utils/*": [ 34 | "utils/*" 35 | ], 36 | "@app/*": [ 37 | "*" 38 | ] 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Documentação da Api 5 | 6 | 7 | 8 | 9 | 10 | 11 |

Documentação

12 |
13 | 37 |
38 | 39 | 40 | --------------------------------------------------------------------------------