├── .env.template ├── .github └── workflows │ └── python-deploy.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ └── 033c9e1d95f5_make_migrations.py ├── auth ├── __init__.py ├── auth_bearer.py ├── auth_handler.py └── hashers.py ├── config ├── __init__.py └── database.py ├── core ├── __init__.py ├── deps.py └── settings.py ├── docker-compose.yml ├── ledger.sqlite ├── ledger ├── __init__.py ├── api.py ├── router.py └── services │ ├── __init__.py │ ├── functions.py │ └── operations.py ├── main.py ├── models ├── __init__.py ├── ledger.py └── user.py ├── orm ├── __init__.py ├── aggregate.py ├── base.py ├── ledger.py └── users.py ├── pytest.ini ├── requirements.dev.txt ├── requirements.txt ├── run-migrations.sh ├── schemas ├── __init__.py ├── auth.py ├── ledger.py └── user.py ├── tests ├── __init__.py ├── conftest.py ├── test_ledger.py └── test_user.py └── users ├── __init__.py ├── api.py ├── auth.py ├── router.py └── services.py /.env.template: -------------------------------------------------------------------------------- 1 | JWT_SECRET = 2 | JWT_ALGORITHM = # HS256 3 | TOKEN_LIFETIME = -------------------------------------------------------------------------------- /.github/workflows/python-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Pull Changes & Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | pull_changes: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Pull New Changes 12 | uses: appleboy/ssh-action@v0.1.5 13 | with: 14 | host: ${{ secrets.SSH_HOST }} 15 | port: ${{ secrets.SSH_PORT }} 16 | username: ${{ secrets.SSH_USERNAME }} 17 | key: ${{ secrets.SSH_KEY }} 18 | script: | 19 | cd /home/ubuntu/fastapi-ledger-system 20 | git pull origin main 21 | 22 | deploy_backend: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Deploy Backend 26 | uses: appleboy/ssh-action@v0.1.5 27 | with: 28 | host: ${{ secrets.SSH_HOST }} 29 | port: ${{ secrets.SSH_PORT }} 30 | username: ${{ secrets.SSH_USERNAME }} 31 | key: ${{ secrets.SSH_KEY }} 32 | script: | 33 | cd /home/ubuntu/fastapi-ledger-system 34 | sudo docker-compose run web alembic upgrade head 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package dependencies 2 | env/ 3 | .env 4 | 5 | # cache dependencies 6 | __pycache__/ 7 | 8 | # db 9 | test_db.sqlite 10 | ledger.sqlite 11 | 12 | # dev 13 | .vscode -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # pull from tiangolo image 2 | FROM tiangolo/uvicorn-gunicorn:python3.9 3 | 4 | # set working directory 5 | WORKDIR /ledger_be 6 | 7 | # set environment variables 8 | ENV PYTHONDONTWRITEBYTECODE 1 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | EXPOSE 8000 12 | 13 | COPY . /ledger_be/ 14 | 15 | # install dependencies 16 | RUN pip install --upgrade pip setuptools wheel \ 17 | && pip install -r /ledger_be/requirements.dev.txt \ 18 | && rm -rf /root/.cache/pip -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | buildserver: 2 | docker compose build 3 | 4 | runserver: 5 | docker compose up 6 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: uvicorn main:app --host 0.0.0.0 --port $PORT -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI-Ledger 2 | 3 | Have you ever been curious as to how fintech applications are built? This system demonstrates the basic functionalities of a fintech product. 4 | 5 | ## Technologies 6 | 7 | - FastAPI 8 | - PostgreSQL 9 | - SQLAlchemy (ORM) 10 | - Docker and Docker-compose 11 | - Alembic (Database migrations) 12 | - Pytest (Unit testing) 13 | 14 | ## Problem Statement 15 | 16 | Build a ledger system with the following functionalities: 17 | 18 | - [x] ( Deposit Money ) Credit X amount to one of the user’s account 19 | - [x] ( Withdraw Money ) Debit X amount from one of the user’s account 20 | - [x] Transfer money from one account to another account for a single user 21 | - [x] Transfer money from one account of one user to another user 22 | - [x] Get balance for a user 23 | - [x] Get balance for an account of a user 24 | - [x] User can have (10) maximum wallets 25 | 26 | ## Getting Started 27 | 28 | To get the service up and running, follow the steps below: 29 | 30 | 1). Run the commands below in your terminal: 31 | 32 | ```bash 33 | git clone git@github.com:aybruhm/fastapi-ledger-system.git 34 | ``` 35 | 36 | 2). Change directory to fastapi-ledger-system: 37 | 38 | ```bash 39 | cd fastapi-ledger-system 40 | ``` 41 | 42 | 3). Rename the `.env.template` file to `.env` and update the values. 43 | 44 | 4). Build and run the service with: 45 | 46 | ```bash 47 | docker-compose up --build 48 | ``` 49 | 50 | The service will build and run on port `8080`. 51 | 52 | 5). Launch a new terminal session and run the following commands: 53 | 54 | ```bash 55 | chmod +x run-migrations.sh 56 | ``` 57 | 58 | ```bash 59 | ./run-migrations.sh 60 | ``` 61 | 62 | The above commands would activate the script file and when ran- will make database migrations for you automatically. 63 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # the output encoding used when revision files 55 | # are written from script.py.mako 56 | # output_encoding = utf-8 57 | 58 | # sqlalchemy.url = driver://user:pass@localhost/dbname 59 | 60 | 61 | [post_write_hooks] 62 | # post_write_hooks defines scripts or Python functions that are run 63 | # on newly generated revision scripts. See the documentation for further 64 | # detail and examples 65 | 66 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 67 | # hooks = black 68 | # black.type = console_scripts 69 | # black.entrypoint = black 70 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 71 | 72 | # Logging configuration 73 | [loggers] 74 | keys = root,sqlalchemy,alembic 75 | 76 | [handlers] 77 | keys = console 78 | 79 | [formatters] 80 | keys = generic 81 | 82 | [logger_root] 83 | level = WARN 84 | handlers = console 85 | qualname = 86 | 87 | [logger_sqlalchemy] 88 | level = WARN 89 | handlers = 90 | qualname = sqlalchemy.engine 91 | 92 | [logger_alembic] 93 | level = INFO 94 | handlers = 95 | qualname = alembic 96 | 97 | [handler_console] 98 | class = StreamHandler 99 | args = (sys.stderr,) 100 | level = NOTSET 101 | formatter = generic 102 | 103 | [formatter_generic] 104 | format = %(levelname)-5.5s [%(name)s] %(message)s 105 | datefmt = %H:%M:%S 106 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | from logging.config import fileConfig 3 | 4 | # Alembic Imports 5 | from alembic import context 6 | 7 | # Own Imports 8 | from config.database import DB_ENGINE, SQLALCHEMY_DATABASE_URL 9 | from models.user import Base 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | if config.config_file_name is not None: 18 | fileConfig(config.config_file_name) 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | target_metadata = Base.metadata 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | 31 | 32 | def run_migrations_offline() -> None: 33 | """Run migrations in 'offline' mode. 34 | 35 | This configures the context with just a URL 36 | and not an Engine, though an Engine is acceptable 37 | here as well. By skipping the Engine creation 38 | we don't even need a DBAPI to be available. 39 | 40 | Calls to context.execute() here emit the given string to the 41 | script output. 42 | 43 | """ 44 | context.configure( 45 | url=SQLALCHEMY_DATABASE_URL, 46 | target_metadata=target_metadata, 47 | literal_binds=True, 48 | dialect_opts={"paramstyle": "named"}, 49 | ) 50 | 51 | with context.begin_transaction(): 52 | context.run_migrations() 53 | 54 | 55 | def run_migrations_online() -> None: 56 | """Run migrations in 'online' mode. 57 | 58 | In this scenario we need to create an Engine 59 | and associate a connection with the context. 60 | 61 | """ 62 | 63 | with DB_ENGINE.connect() as connection: 64 | context.configure( 65 | connection=connection, target_metadata=target_metadata 66 | ) 67 | 68 | with context.begin_transaction(): 69 | context.run_migrations() 70 | 71 | 72 | if context.is_offline_mode(): 73 | run_migrations_offline() 74 | else: 75 | run_migrations_online() 76 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /alembic/versions/033c9e1d95f5_make_migrations.py: -------------------------------------------------------------------------------- 1 | """Make migrations 2 | 3 | Revision ID: 033c9e1d95f5 4 | Revises: 5 | Create Date: 2023-01-20 08:27:33.364964 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '033c9e1d95f5' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(), nullable=True), 24 | sa.Column('email', sa.String(), nullable=True), 25 | sa.Column('password', sa.String(), nullable=True), 26 | sa.Column('is_active', sa.Boolean(), nullable=True), 27 | sa.Column('is_admin', sa.Boolean(), nullable=True), 28 | sa.Column('created_at', sa.DateTime(), nullable=True), 29 | sa.Column('updated_at', sa.DateTime(), nullable=True), 30 | sa.PrimaryKeyConstraint('id') 31 | ) 32 | op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) 33 | op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) 34 | op.create_table('users_wallet', 35 | sa.Column('id', sa.Integer(), nullable=False), 36 | sa.Column('user', sa.Integer(), nullable=True), 37 | sa.Column('title', sa.String(), nullable=True), 38 | sa.Column('amount', sa.Integer(), nullable=True), 39 | sa.Column('created_at', sa.DateTime(), nullable=True), 40 | sa.Column('updated_at', sa.DateTime(), nullable=True), 41 | sa.ForeignKeyConstraint(['user'], ['users.id'], ), 42 | sa.PrimaryKeyConstraint('id') 43 | ) 44 | op.create_index(op.f('ix_users_wallet_id'), 'users_wallet', ['id'], unique=False) 45 | # ### end Alembic commands ### 46 | 47 | 48 | def downgrade() -> None: 49 | # ### commands auto generated by Alembic - please adjust! ### 50 | op.drop_index(op.f('ix_users_wallet_id'), table_name='users_wallet') 51 | op.drop_table('users_wallet') 52 | op.drop_index(op.f('ix_users_id'), table_name='users') 53 | op.drop_index(op.f('ix_users_email'), table_name='users') 54 | op.drop_table('users') 55 | # ### end Alembic commands ### 56 | -------------------------------------------------------------------------------- /auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /auth/auth_bearer.py: -------------------------------------------------------------------------------- 1 | # FastAPI Imports 2 | from fastapi import Request, HTTPException 3 | from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 4 | 5 | # Own Imports 6 | from auth.auth_handler import authentication 7 | 8 | 9 | class JWTBearer(HTTPBearer): 10 | """Responsible for persisting authentication on our API routes.""" 11 | 12 | def __init__(self, auto_error: bool = True): 13 | """ 14 | The method __init__() is a constructor of the class JWTBearer 15 | 16 | :param auto_error: If True, the middleware will raise an error 17 | if the token is invalid. If 18 | False, the middleware will return a 401 response, defaults to True 19 | 20 | :type auto_error: bool (optional) 21 | """ 22 | super(JWTBearer, self).__init__(auto_error=auto_error) 23 | 24 | async def __call__(self, request: Request): 25 | """ 26 | This method checks if the credentials are valid, 27 | return the credentials. If not, raise an exception. 28 | 29 | :param request: The request object 30 | :type request: Request 31 | 32 | :return: The token 33 | """ 34 | authorization_credentials: HTTPAuthorizationCredentials = await super( 35 | JWTBearer, self 36 | ).__call__(request) 37 | 38 | if authorization_credentials: 39 | if authorization_credentials.scheme != "Bearer": 40 | raise HTTPException(403, {"message": "Invalid authentication scheme."}) 41 | 42 | if not self.verify_jwt_token(authorization_credentials.credentials): 43 | raise HTTPException(403, {"message": "Invalid token or expired token."}) 44 | 45 | return authorization_credentials.credentials 46 | else: 47 | raise HTTPException(403, {"message": "Invalid authorization code."}) 48 | 49 | def verify_jwt_token(self, token: str) -> bool: 50 | """ 51 | This method takes a JWT token as an argument, 52 | decodes it and returns a boolean value. 53 | 54 | :param token: The token that you want to verify 55 | :type token: str 56 | 57 | :return: A boolean value. 58 | """ 59 | payload = authentication.decode_jwt(token) 60 | 61 | if payload: 62 | return True 63 | return False 64 | 65 | 66 | jwt_bearer = JWTBearer() -------------------------------------------------------------------------------- /auth/auth_handler.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | from datetime import datetime, timedelta 3 | from typing import Dict, Any, Union 4 | 5 | # FastAPI Imports 6 | from fastapi import HTTPException 7 | 8 | # PyJWT Imports 9 | import jwt 10 | 11 | # Third Party Imports 12 | from core.settings import ledger_settings 13 | 14 | 15 | # JWT Env Definitions 16 | JWT_SECRET = ledger_settings.JWT_SECRET_KEY 17 | JWT_ALGORITHM = ledger_settings.JWT_ALGORITHM 18 | TOKEN_LIFETIME = ledger_settings.TOKEN_LIFETIME 19 | 20 | 21 | class AuthHandler: 22 | """ 23 | Responsible for: 24 | 25 | - signing, 26 | - encoding/decoding of tokens 27 | """ 28 | 29 | def __init__( 30 | self, 31 | secret: str = JWT_SECRET, 32 | algorithm: str = JWT_ALGORITHM, 33 | token_lifetime: int = TOKEN_LIFETIME, 34 | ): 35 | """ 36 | This method initializes the class with the secret, 37 | algorithm, and token lifetime. 38 | 39 | :param secret: The secret key used to sign the JWT 40 | :type secret: str 41 | 42 | :param algorithm: The algorithm used to sign the token 43 | :type algorithm: str 44 | 45 | :param token_lifetime: The lifetime of the token in seconds 46 | :type token_lifetime: int 47 | """ 48 | self.JWT_SECRET = secret 49 | self.JWT_ALGORITHM = algorithm 50 | self.TOKEN_LIFETIME = token_lifetime 51 | 52 | def sign_jwt(self, user_id: int) -> Dict[str, Any]: 53 | """ 54 | This method creates a JWT token with a user_id and expiration date, 55 | signs it with a secret key, and returns a response with the token. 56 | 57 | :param user_id: The user's ID 58 | :type user_id: int 59 | 60 | :return: A dictionary with the token and the expiration time. 61 | """ 62 | payload = { 63 | "user_id": user_id, 64 | "expires": str( 65 | datetime.now() + timedelta(minutes=self.TOKEN_LIFETIME) 66 | ), 67 | } 68 | token = jwt.encode( 69 | payload, self.JWT_SECRET, algorithm=self.JWT_ALGORITHM 70 | ) 71 | return {"access_token": token} 72 | 73 | def decode_jwt(self, token: str) -> Union[Dict, Exception]: 74 | """ 75 | This method checks if the token is valid, 76 | return the decoded token, otherwise return an empty dictionary. 77 | 78 | :param token: The token to decode 79 | :type token: str 80 | 81 | :return: A dictionary of the decoded token | error message. 82 | """ 83 | 84 | try: 85 | decoded_token = jwt.decode( 86 | token, self.JWT_SECRET, algorithms=self.JWT_ALGORITHM 87 | ) 88 | except (jwt.DecodeError, Exception): 89 | raise HTTPException(403, {"message": "Token invalid."}) 90 | 91 | if ( 92 | datetime.strptime(decoded_token["expires"], "%Y-%m-%d %H:%M:%S.%f") 93 | >= datetime.now() 94 | ): 95 | return decoded_token 96 | raise HTTPException(400, {"message": "Token expired."}) 97 | 98 | 99 | authentication = AuthHandler() 100 | -------------------------------------------------------------------------------- /auth/hashers.py: -------------------------------------------------------------------------------- 1 | # Third Party Imports 2 | from passlib.context import CryptContext 3 | 4 | 5 | class PasswordHasher: 6 | """ 7 | Responsible for the following: 8 | 9 | - hashing password 10 | - check/verify hashed password 11 | """ 12 | 13 | password_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 14 | 15 | def hash_password(self, password: str) -> str: 16 | """ 17 | This method takes a password as a string and returns a 18 | hashed password as a string. 19 | 20 | :param password: The password to hash 21 | :type password: str 22 | 23 | :return: The hashed password. 24 | """ 25 | return self.password_context.hash(password) 26 | 27 | def check_password(self, password: str, hashed_password: str) -> bool: 28 | """ 29 | This method checks if the given password matches the hashed_password. 30 | 31 | :param password: The password to be checked 32 | :type password: str 33 | 34 | :param user: The hashed password that we're checking the password for 35 | :type hashed_password: str 36 | 37 | :return bool: The password context is being returned. 38 | """ 39 | return self.password_context.verify(password, hashed_password) 40 | 41 | 42 | pwd_hasher = PasswordHasher() -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/database.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | import os 3 | 4 | # SQLAlchemy Imports 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.ext.declarative import declarative_base 7 | from sqlalchemy.orm import sessionmaker, scoped_session 8 | 9 | # Third Party Imports 10 | from databases import Database 11 | 12 | 13 | # SQLALCHEMY_DATABASE_URL = "sqlite:///./ledger.sqlite" 14 | SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL") 15 | 16 | DB_ENGINE = create_engine( 17 | SQLALCHEMY_DATABASE_URL 18 | ) # connect_args={"check_same_thread": False} is needed only for SQLite. 19 | # It's not needed for other databases. 20 | 21 | # Construct a session maker 22 | session_factory = sessionmaker(autocommit=False, autoflush=False, bind=DB_ENGINE) 23 | SessionLocal = scoped_session(session_factory) 24 | 25 | # Construct a base class for declarative class definitions. 26 | Base = declarative_base() 27 | 28 | # Construct a db connector to connect, shutdown database 29 | db_connect = Database(SQLALCHEMY_DATABASE_URL) 30 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/deps.py: -------------------------------------------------------------------------------- 1 | # FastAPI Imports 2 | from fastapi import Depends, HTTPException 3 | 4 | # Own Imports Imports 5 | from models.user import User 6 | from orm.users import users_orm 7 | from auth.auth_bearer import jwt_bearer 8 | 9 | # 3rd Party Imports 10 | import jwt 11 | from decouple import config 12 | 13 | 14 | async def get_current_user(token: str = Depends(jwt_bearer)) -> User: 15 | """ 16 | This function takes a JWT token, 17 | and returns the user that the token belongs to. 18 | 19 | :param token: str = Depends(JWTBearer()) 20 | :type token: str 21 | 22 | :return: The user object. 23 | """ 24 | 25 | try: 26 | payload = jwt.decode( 27 | token, config("JWT_SECRET"), algorithms=[config("JWT_ALGORITHM")] 28 | ) 29 | except (jwt.PyJWTError, Exception): 30 | raise HTTPException(403, {"message": "Could not validate token."}) 31 | 32 | user = await users_orm.get(payload["user_id"]) 33 | if not user: 34 | raise HTTPException(404, {"message": "User does not exist!"}) 35 | return user 36 | 37 | 38 | async def get_admin_user( 39 | current_user: User = Depends(get_current_user), 40 | ) -> User: 41 | """ 42 | This function returns an admin user based on the provided token; 43 | otherwise, raise an authorized exception. 44 | """ 45 | 46 | if not current_user.is_admin: 47 | raise HTTPException(401, {"message": "Admin priviledge is required!"}) 48 | 49 | elif current_user is None: 50 | return None 51 | 52 | return current_user 53 | -------------------------------------------------------------------------------- /core/settings.py: -------------------------------------------------------------------------------- 1 | # Pydantic Imports 2 | from pydantic import BaseSettings 3 | 4 | # Third Party Imports 5 | from decouple import config 6 | 7 | 8 | class Settings(BaseSettings): 9 | """Base configuration settings""" 10 | 11 | JWT_SECRET_KEY: str = config("JWT_SECRET", cast=str) 12 | JWT_ALGORITHM: str = config("JWT_ALGORITHM", cast=str) 13 | TOKEN_LIFETIME: int = config("TOKEN_LIFETIME", cast=int) 14 | USE_TEST_DB: bool = config("USE_TEST_DB", cast=bool) 15 | 16 | TITLE: str = "Ledger System" 17 | DESCRIPTION: str = "A fintech backend ledger system built with FastAPI." 18 | CONTACT: dict = { 19 | "name": "Abraham Israel", 20 | "url": "http://linkedin.com/in/abraham-israel", 21 | "email": "israelvictory87@gmail.com", 22 | } 23 | API_VERSION: float = 1.0 24 | LICENSE: dict = { 25 | "name": "CC0 1.0 Universal", 26 | "url": "https://github.com/aybruhm/fastapi-ledger-system/blob/main/LICENSE", 27 | } 28 | 29 | 30 | ledger_settings = Settings() 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | 5 | web: 6 | build: ./ 7 | command: "uvicorn main:app --host='0.0.0.0' --port=8000 --reload" 8 | volumes: 9 | - ./:/ledger_be 10 | ports: 11 | - 8000:8000 12 | environment: 13 | - DATABASE_URL=postgresql://abram:ledger_b3_2022_db@db/ledger-db 14 | - TEST_DATABASE_URL=postgresql://abram:ledger_b3_2022_testdb@test_db/ledger-testdb 15 | depends_on: 16 | - db 17 | - test_db 18 | 19 | db: 20 | image: postgres:15.1-alpine 21 | volumes: 22 | - postgres_data:/var/lib/postgresql/data/ 23 | expose: 24 | - 5432 25 | environment: 26 | - POSTGRES_USER=abram 27 | - POSTGRES_PASSWORD=ledger_b3_2022_db 28 | - POSTGRES_DB=ledger-db 29 | healthcheck: 30 | test: 31 | [ 32 | 'CMD-SHELL', 33 | 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB' 34 | ] 35 | interval: 10s 36 | timeout: 5s 37 | retries: 5 38 | 39 | test_db: 40 | image: postgres:15.1-alpine 41 | volumes: 42 | - test_postgres_data:/var/lib/test_postgresql/data/ 43 | expose: 44 | - 5432 45 | environment: 46 | - POSTGRES_USER=abram 47 | - POSTGRES_PASSWORD=ledger_b3_2022_testdb 48 | - POSTGRES_DB=ledger-testdb 49 | healthcheck: 50 | test: 51 | [ 52 | 'CMD-SHELL', 53 | 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB' 54 | ] 55 | interval: 10s 56 | timeout: 5s 57 | retries: 5 58 | 59 | volumes: 60 | postgres_data: 61 | test_postgres_data: 62 | -------------------------------------------------------------------------------- /ledger.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aybruhm/fastapi-ledger-system/HEAD/ledger.sqlite -------------------------------------------------------------------------------- /ledger/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ledger/api.py: -------------------------------------------------------------------------------- 1 | # FastAPI Imports 2 | from fastapi import HTTPException, Depends 3 | 4 | # Own Imports 5 | from ledger.router import router 6 | from core.deps import get_current_user 7 | from models.user import User as UserModel 8 | from ledger.services.operations import ledger_operations 9 | from ledger.services.functions import ( 10 | get_all_wallets_by_user, 11 | create_wallet as create_user_wallet, 12 | ) 13 | from schemas.ledger import ( 14 | Wallet, 15 | Wallet2UserWalletTransfer, 16 | Wallet2WalletTransfer, 17 | WalletCreate, 18 | WalletDeposit, 19 | WalletWithdraw, 20 | ) 21 | 22 | 23 | @router.post("/wallets/", response_model=Wallet) 24 | async def create_wallet( 25 | wallet: WalletCreate, 26 | current_user: UserModel = Depends(get_current_user), 27 | ): 28 | if current_user.id != wallet.user: 29 | raise HTTPException( 30 | 401, {"message": "Unauthorized to perform this action!"} 31 | ) 32 | 33 | db_wallet = await create_user_wallet(wallet) 34 | return db_wallet 35 | 36 | 37 | @router.get("/wallets/", response_model=list[Wallet]) 38 | async def get_wallets( 39 | skip: int = 0, 40 | limit: int = 100, 41 | current_user: UserModel = Depends(get_current_user), 42 | ): 43 | 44 | if current_user: 45 | db_wallets = await get_all_wallets_by_user( 46 | skip, limit, current_user.id 47 | ) 48 | return db_wallets 49 | 50 | raise HTTPException( 51 | 401, {"message": "Unauthorized to perform this action!"} 52 | ) 53 | 54 | 55 | @router.post("/deposit/") 56 | async def deposit_money( 57 | deposit: WalletDeposit, 58 | current_user: UserModel = Depends(get_current_user), 59 | ) -> dict: 60 | 61 | if current_user.id != deposit.user: 62 | raise HTTPException( 63 | 401, {"message": "Unauthorized to perform this action!"} 64 | ) 65 | 66 | await ledger_operations.deposit_money_to_wallet(deposit) 67 | return {"message": f"NGN{deposit.amount} deposit successful!"} 68 | 69 | 70 | @router.post("/withdraw/") 71 | async def withdraw_money( 72 | withdraw: WalletWithdraw, 73 | current_user: UserModel = Depends(get_current_user), 74 | ) -> dict: 75 | 76 | if current_user.id != withdraw.user: 77 | raise HTTPException( 78 | 401, {"message": "Unauthorized to perform this action!"} 79 | ) 80 | 81 | await ledger_operations.withdraw_money_from_wallet(withdraw) 82 | return {"message": f"NGN{withdraw.amount} withdrawn successful!"} 83 | 84 | 85 | @router.post("/transfer/wallet-to-wallet/") 86 | async def wallet_to_wallet_transfer( 87 | withdraw: Wallet2WalletTransfer, 88 | current_user: UserModel = Depends(get_current_user), 89 | ) -> dict: 90 | 91 | if current_user.id != withdraw.user: 92 | raise HTTPException( 93 | 401, {"message": "Unauthorized to perform this action!"} 94 | ) 95 | 96 | await ledger_operations.withdraw_from_to_wallet_transfer(withdraw) 97 | return { 98 | "message": f"NGN{withdraw.amount} was transfered from \ 99 | W#{withdraw.wallet_from} wallet to W#{withdraw.wallet_to} wallet!" 100 | } 101 | 102 | 103 | @router.post("/transfer/wallet-to-user/") 104 | async def wallet_to_user_transfer( 105 | withdraw: Wallet2UserWalletTransfer, 106 | current_user: UserModel = Depends(get_current_user), 107 | ) -> dict: 108 | 109 | if current_user.id != withdraw.user: 110 | raise HTTPException( 111 | 401, {"message": "Unauthorized to perform this action!"} 112 | ) 113 | 114 | await ledger_operations.withdraw_from_to_user_wallet_transfer(withdraw) 115 | return { 116 | "message": f"Transferred NGN{withdraw.amount} \ 117 | to U#{withdraw.user_to} W#{withdraw.wallet_to} wallet." 118 | } 119 | 120 | 121 | @router.get("/balance/") 122 | async def total_wallet_balance( 123 | current_user: UserModel = Depends(get_current_user), 124 | ) -> dict: 125 | 126 | balance = await ledger_operations.get_total_wallet_balance(current_user.id) 127 | return {"message": f"Total wallet balance is NGN{balance}"} 128 | 129 | 130 | @router.get("/balance/wallet/") 131 | async def wallet_balance( 132 | wallet_id: int, 133 | current_user: UserModel = Depends(get_current_user), 134 | ) -> dict: 135 | 136 | balance = await ledger_operations.get_wallet_balance( 137 | current_user.id, wallet_id 138 | ) 139 | 140 | if balance is None: 141 | raise HTTPException(404, {"message": "Wallet does not exist!"}) 142 | return {"message": f"Wallet balance is NGN{balance.amount}"} 143 | -------------------------------------------------------------------------------- /ledger/router.py: -------------------------------------------------------------------------------- 1 | # FastAPI Imports 2 | from fastapi import APIRouter, Depends 3 | 4 | # Own Imports 5 | from auth.auth_bearer import jwt_bearer 6 | 7 | 8 | # initialize router 9 | router = APIRouter(dependencies=[Depends(jwt_bearer)]) 10 | -------------------------------------------------------------------------------- /ledger/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ledger/services/functions.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | from typing import List 3 | 4 | # ORM Imports 5 | from orm.ledger import ledger_orm 6 | from schemas.ledger import WalletCreate 7 | from models.ledger import Wallet as UserWallet 8 | 9 | 10 | async def create_wallet(wallet: WalletCreate) -> UserWallet: 11 | """ 12 | It creates a new wallet in the database. 13 | 14 | :param wallet: schemas.WalletCreate 15 | :type wallet: schemas.WalletCreate 16 | 17 | :return: The wallet that was created. 18 | """ 19 | 20 | wallet = await ledger_orm.create(wallet) 21 | return wallet 22 | 23 | 24 | async def get_all_wallets_by_user( 25 | skip: int, 26 | limit: int, 27 | user_id: int 28 | ) -> List[UserWallet]: 29 | """ 30 | This function gets all wallets for a user. 31 | 32 | :param skip: The number of records to skip 33 | :type skip: int 34 | 35 | :param limit: The maximum number of items to return 36 | :type limit: int 37 | 38 | :param user_id: The id of the user whose wallets you want to retrieve 39 | :type user_id: int 40 | 41 | :return: A list of all wallets for a given user. 42 | """ 43 | return await ledger_orm.filter( 44 | **{"skip": skip, "limit": limit, "user_id": user_id} 45 | ) 46 | -------------------------------------------------------------------------------- /ledger/services/operations.py: -------------------------------------------------------------------------------- 1 | # SQLAlchemy Imports 2 | from sqlalchemy.orm import Session 3 | 4 | # Own Imports 5 | from config.database import SessionLocal 6 | from models.ledger import Wallet as UserWallet 7 | from schemas.ledger import ( 8 | Wallet2UserWalletTransfer, 9 | WalletWithdraw, 10 | WalletDeposit, 11 | Wallet2WalletTransfer, 12 | ) 13 | from orm.ledger import ledger_orm 14 | from orm.aggregate import ledger_aggregate_orm 15 | 16 | 17 | class LedgerOperations: 18 | """ 19 | This service is responsible for: 20 | 21 | - depositing money to wallet 22 | - withdrawing money from wallet 23 | - wallet to wallet withdraw transfer 24 | - wallet to user wallet transfer 25 | - get total wallet balance 26 | - get wallet balance 27 | """ 28 | 29 | def __init__(self, db: Session): 30 | self.db = db 31 | 32 | async def deposit_money_to_wallet( 33 | self, deposit: WalletDeposit 34 | ) -> None: 35 | """ 36 | This function deposit x amount to the user wallet. 37 | 38 | :param deposit: schemas.WalletDeposit 39 | :type deposit: schemas.WalletDeposit 40 | """ 41 | 42 | topup_wallet = ledger_orm.partial_filter( 43 | {"wallet_id": deposit.id, "user_id": deposit.user} 44 | ) 45 | topup_wallet.amount += deposit.amount 46 | 47 | self.db.commit() 48 | 49 | async def withdraw_money_from_wallet( 50 | self, withdraw: WalletWithdraw 51 | ) -> None: 52 | """ 53 | The function withdraws x amount from the user wallet. 54 | 55 | :param withdraw: schemas.WalletWithdraw 56 | :type withdraw: schemas.WalletWithdraw 57 | """ 58 | 59 | withdraw_wallet = ledger_orm.partial_filter( 60 | {"wallet_id": withdraw.id, "user_id": withdraw.user} 61 | ) 62 | withdraw_wallet.amount -= withdraw.amount 63 | 64 | self.db.commit() 65 | 66 | async def withdraw_from_to_wallet_transfer( 67 | self, withdraw: Wallet2WalletTransfer 68 | ) -> None: 69 | """ 70 | This function is responsible for transferring x amount 71 | from wallet y to wallet z. 72 | 73 | :param withdraw: schemas.Wallet2WalletTransfer 74 | :type withdraw: schemas.Wallet2WalletTransfer 75 | """ 76 | 77 | from_wallet = ledger_orm.partial_filter( 78 | {"wallet_id": withdraw.wallet_from, "user_id": withdraw.user} 79 | ) 80 | to_wallet = ledger_orm.partial_filter( 81 | {"wallet_id": withdraw.wallet_to, "user_id": withdraw.user} 82 | ) 83 | 84 | from_wallet.amount -= withdraw.amount 85 | to_wallet.amount += withdraw.amount 86 | 87 | self.db.commit() 88 | 89 | async def withdraw_from_to_user_wallet_transfer( 90 | self, withdraw: Wallet2UserWalletTransfer 91 | ) -> None: 92 | """ 93 | This function is responsible for transferring x amount 94 | from wallet y to user z wallet. 95 | 96 | :param withdraw: schemas.Wallet2UserWalletTransfer 97 | :type withdraw: schemas.Wallet2UserWalletTransfer 98 | """ 99 | 100 | from_wallet = ledger_orm.partial_filter( 101 | {"wallet_id": withdraw.wallet_from, "user_id": withdraw.user} 102 | ) 103 | to_wallet = ledger_orm.partial_filter( 104 | {"wallet_id": withdraw.wallet_to, "user_id": withdraw.user_to} 105 | ) 106 | 107 | from_wallet.amount -= withdraw.amount 108 | to_wallet.amount += withdraw.amount 109 | 110 | self.db.commit() 111 | 112 | async def get_total_wallet_balance(self, user_id: int) -> int: 113 | """ 114 | This function gets the total sum amomut of the user wallets.t 115 | 116 | :param user_id: The user id of the user whose wallets you want to get 117 | :type user_id: int 118 | 119 | :return: The total balance of all wallets for a user. 120 | """ 121 | 122 | wallet = await ledger_aggregate_orm.total_sum(user_id) 123 | return wallet[0][0] 124 | 125 | async def get_wallet_balance( 126 | self, user_id: int, wallet_id: int 127 | ) -> UserWallet: 128 | """ 129 | This function gets the balance of a single wallet. 130 | 131 | :param user_id: The user_id of the user who owns the wallet 132 | :type user_id: int 133 | 134 | :param wallet_id: The id of the wallet you want to get the balance of 135 | :type wallet_id: int 136 | 137 | :return: The balance of the wallet 138 | """ 139 | 140 | wallet = await ledger_orm.get(user_id, wallet_id) 141 | return wallet 142 | 143 | 144 | ledger_operations = LedgerOperations(SessionLocal) 145 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # Uvicorn Imports 2 | import uvicorn 3 | 4 | # FastAPI Imports 5 | from fastapi import FastAPI 6 | from fastapi.middleware.cors import CORSMiddleware 7 | 8 | # Own Imports 9 | from config.database import db_connect 10 | from core.settings import ledger_settings 11 | 12 | # Routers Imports 13 | from users.auth import router as auth_router 14 | from users.api import router as users_router 15 | from ledger.api import router as ledger_router 16 | 17 | 18 | # Initialize fastapi 19 | app = FastAPI( 20 | title=ledger_settings.TITLE, 21 | description=ledger_settings.DESCRIPTION, 22 | version=ledger_settings.API_VERSION, 23 | contact=ledger_settings.CONTACT, 24 | license_info=ledger_settings.LICENSE, 25 | ) 26 | app.add_middleware( 27 | CORSMiddleware, 28 | allow_origins=["*"], 29 | allow_methods=["POST", "GET"], 30 | allow_headers=["*"], 31 | allow_credentials=True, 32 | ) 33 | 34 | # Include routers to base router 35 | app.include_router(auth_router) 36 | app.include_router(users_router, tags=["Users"]) 37 | app.include_router(ledger_router, tags=["Ledger"]) 38 | 39 | 40 | @app.on_event("startup") 41 | async def startup(): 42 | await db_connect.connect() 43 | 44 | 45 | @app.on_event("shutdown") 46 | async def disconnect(): 47 | await db_connect.disconnect() 48 | 49 | 50 | @app.get("/", tags=["Root"]) 51 | async def home() -> dict: 52 | """ 53 | Ledger home 54 | 55 | Returns: 56 | dict: version and description 57 | """ 58 | return {"v1": "Welcome to Ledger Fintech!"} 59 | 60 | 61 | if __name__ == "__main__": 62 | uvicorn.run("main:app", host="127.0.0.1", reload=True) 63 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /models/ledger.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | import datetime 3 | 4 | # SQLAlchemy Imports 5 | from sqlalchemy import Column, ForeignKey, Integer, String, DateTime 6 | from sqlalchemy.orm import relationship 7 | 8 | # Core Imports 9 | from config.database import Base 10 | 11 | 12 | class Wallet(Base): 13 | __tablename__ = "users_wallet" 14 | 15 | id = Column(Integer, primary_key=True, index=True) 16 | user = Column(Integer, ForeignKey("users.id")) 17 | title = Column(String) 18 | amount = Column(Integer, default=0) 19 | created_at = Column(DateTime, default=datetime.datetime.now) 20 | updated_at = Column(DateTime, onupdate=datetime.datetime.now) 21 | 22 | owner = relationship("User", back_populates="wallets") 23 | -------------------------------------------------------------------------------- /models/user.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | import datetime 3 | 4 | # SQLAlchemy Imports 5 | from sqlalchemy import Boolean, Column, Integer, String, DateTime 6 | from sqlalchemy.orm import relationship 7 | 8 | # Config Imports 9 | from config.database import Base 10 | 11 | # Wallet Imports 12 | from models.ledger import Wallet 13 | 14 | 15 | class User(Base): 16 | __tablename__ = "users" 17 | 18 | id = Column(Integer, primary_key=True, index=True) 19 | name = Column(String) 20 | email = Column(String, unique=True, index=True) 21 | password = Column(String) 22 | is_active = Column(Boolean, default=True) 23 | is_admin = Column(Boolean, default=False) 24 | created_at = Column(DateTime, default=datetime.datetime.now) 25 | updated_at = Column(DateTime, onupdate=datetime.datetime.now) 26 | 27 | wallets = relationship(Wallet, back_populates="owner") 28 | -------------------------------------------------------------------------------- /orm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /orm/aggregate.py: -------------------------------------------------------------------------------- 1 | # SQLAlchemy Imports 2 | from sqlalchemy import func 3 | 4 | # Own Imports 5 | from orm.ledger import BaseLedgerORM, Userwallet 6 | 7 | 8 | class LedgerAggregateORM(BaseLedgerORM): 9 | """ORM responsible for performing aggregate operations.""" 10 | 11 | async def total_sum(self, user_id: int): 12 | """This method aggregates the amount of a user wallet.""" 13 | 14 | return ( 15 | self.orm.query(Userwallet) 16 | .join(Userwallet.owner) 17 | .filter(Userwallet.user == user_id) 18 | .with_entities(func.sum(Userwallet.amount)) 19 | .all() 20 | ) 21 | 22 | 23 | ledger_aggregate_orm = LedgerAggregateORM() 24 | -------------------------------------------------------------------------------- /orm/base.py: -------------------------------------------------------------------------------- 1 | # SQLAlchemy Imports 2 | from sqlalchemy.orm import Session 3 | 4 | # Own Imports 5 | from config.database import SessionLocal 6 | from tests.conftest import _get_test_db 7 | from core.settings import ledger_settings 8 | 9 | 10 | class ORMSessionMixin: 11 | """Base orm session mixin for interacting with the database.""" 12 | 13 | def __init__(self): 14 | """ 15 | If we're not using the test database, then get the next 16 | database session from the database pool. , get the next database session 17 | from the test database pool. 18 | """ 19 | self.orm: Session = ( 20 | self.get_db().__next__() 21 | if not ledger_settings.USE_TEST_DB 22 | else _get_test_db().__next__() 23 | ) 24 | 25 | def get_db(self): 26 | """ 27 | This function creates a database session, 28 | yield it to the get_db function, rollback the transaction 29 | if there's an exception and then finally closes the session. 30 | 31 | Yields: 32 | db: scoped database session 33 | """ 34 | db = SessionLocal() 35 | try: 36 | yield db 37 | except Exception: 38 | db.rollback() 39 | finally: 40 | db.close() 41 | -------------------------------------------------------------------------------- /orm/ledger.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | from typing import List 3 | 4 | # FastAPI Imports 5 | from fastapi import HTTPException 6 | 7 | # Own Imports 8 | from orm.base import ORMSessionMixin 9 | from schemas.ledger import WalletCreate 10 | from models.ledger import Wallet as Userwallet 11 | 12 | 13 | class BaseLedgerORM(ORMSessionMixin): 14 | """Base Ledger ORM responsible for interacting with the database.""" 15 | 16 | skip: int = 0 17 | limit: int = 10 18 | 19 | def partial_list(self): 20 | """This method partially retrieves a list of the user wallets.""" 21 | 22 | wallets = self.orm.query(Userwallet) 23 | return wallets 24 | 25 | def partial_filter(self, values: dict[str, int]): 26 | """ 27 | This method partially retrieves a user wallet 28 | and locks the row for update. 29 | 30 | Values: 31 | - walled_id: int 32 | - user_id: int 33 | """ 34 | 35 | wallet = ( 36 | self.partial_list() 37 | .filter( 38 | Userwallet.id == values["wallet_id"], 39 | Userwallet.user == values["user_id"], 40 | ) 41 | .with_for_update() 42 | .first() 43 | ) 44 | 45 | if wallet is None: 46 | raise HTTPException( 47 | 404, 48 | { 49 | "message": f"Wallet ID:{values['wallet_id']} does not exist!" 50 | }, 51 | ) 52 | return wallet 53 | 54 | 55 | class LedgerORM(BaseLedgerORM): 56 | """CRUD Operations for the ledger to interact with the database.""" 57 | 58 | async def get(self, user_id: int, wallet_id: int) -> Userwallet: 59 | """This method retrives a wallet by its id and user/owner id.""" 60 | 61 | wallet = ( 62 | self.partial_list() 63 | .join(Userwallet.owner) 64 | .filter(Userwallet.user == user_id) 65 | .filter(Userwallet.id == wallet_id) 66 | .first() 67 | ) 68 | return wallet 69 | 70 | async def list(self, skip: int, limit: int) -> List[Userwallet]: 71 | """This method retrieves all the wallets in the database.""" 72 | 73 | wallets = ( 74 | self.partial_list() 75 | .offset(self.skip if skip is None else skip) 76 | .limit(self.limit if limit is None else limit) 77 | .all() 78 | ) 79 | return wallets 80 | 81 | async def filter(self, **kwargs) -> List[Userwallet]: 82 | """ 83 | This method filters the list of of user wallets by: 84 | 85 | - the offset (default is 0) 86 | - the limit (default is 10) 87 | - the wallet owner/user id 88 | """ 89 | 90 | wallets = ( 91 | self.partial_list() 92 | .join(Userwallet.owner) 93 | .filter(Userwallet.user == kwargs["user_id"]) 94 | .offset(self.skip if kwargs["skip"] is None else kwargs["skip"]) 95 | .limit(self.limit if kwargs["limit"] is None else kwargs["limit"]) 96 | .all() 97 | ) 98 | return wallets 99 | 100 | async def create(self, wallet: WalletCreate) -> Userwallet: 101 | """This method creates a new wallet.""" 102 | 103 | user_wallet = Userwallet(**wallet.dict()) 104 | 105 | self.orm.add(user_wallet) 106 | self.orm.commit() 107 | self.orm.refresh(user_wallet) 108 | 109 | return user_wallet 110 | 111 | async def update(self, wallet_id: int, **kwargs) -> Userwallet: 112 | """This method updates a wallet.""" 113 | 114 | # achieve safe update operation 115 | # ----------- 116 | 117 | # solution 1 118 | # hange the value directly with the column value 119 | # wallet = self.partial_list().filter_by(id=wallet_id).update(kwargs) 120 | 121 | # solution 2 122 | # locks row for this particular wallet 123 | wallet = ( 124 | self.partial_list() 125 | .filter_by(id=wallet_id) 126 | .with_for_update() 127 | .update(kwargs) 128 | ) 129 | 130 | self.orm.commit() 131 | self.orm.refresh(wallet) 132 | 133 | return wallet 134 | 135 | async def delete(self, wallet_id: int) -> bool: 136 | """This method deletes a wallet.""" 137 | 138 | self.orm.query(Userwallet).filter_by( 139 | Userwallet.id == wallet_id 140 | ).delete() 141 | self.orm.commit() 142 | 143 | return True 144 | 145 | 146 | ledger_orm = LedgerORM() 147 | -------------------------------------------------------------------------------- /orm/users.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | from typing import List 3 | 4 | # SQLAlchemy Imports 5 | from sqlalchemy.orm import joinedload 6 | 7 | # Own Imports 8 | from models.user import User 9 | from schemas.user import UserCreate 10 | from orm.base import ORMSessionMixin 11 | 12 | 13 | class BaseUsersORM(ORMSessionMixin): 14 | def partial_list(self): 15 | """This method partially retrieves a list of users.""" 16 | 17 | return self.orm.query(User) 18 | 19 | 20 | class UsersORM(BaseUsersORM): 21 | """CRUD Operations for the users to interact with the database.""" 22 | 23 | async def get(self, user_id: int) -> User: 24 | """This method gets a user from the database.""" 25 | 26 | user = ( 27 | self.partial_list() 28 | .options(joinedload(User.wallets)) 29 | .filter(User.id == user_id) 30 | .first() 31 | ) 32 | return user 33 | 34 | async def get_email(self, user_email: str) -> User: 35 | """This method gets a user based on their email from the database.""" 36 | 37 | user = ( 38 | self.partial_list() 39 | .options(joinedload(User.wallets)) 40 | .filter(User.email == user_email) 41 | .first() 42 | ) 43 | return user 44 | 45 | async def list(self, skip: int, limit: int) -> List[User]: 46 | """This method gets all the users from the database.""" 47 | 48 | return ( 49 | self.partial_list() 50 | .options(joinedload(User.wallets)) 51 | .offset(skip) 52 | .limit(limit) 53 | .all() 54 | ) 55 | 56 | async def create(self, user: UserCreate, password: str) -> User: 57 | """This method creates a new user.""" 58 | 59 | user = User(name=user.name, email=user.email, password=password) 60 | 61 | self.orm.add(user) 62 | self.orm.commit() 63 | self.orm.refresh(user) 64 | 65 | return user 66 | 67 | async def create_admin( 68 | self, name: str, email: str, password, is_admin: bool 69 | ) -> User: 70 | """This method creates an admin user.""" 71 | 72 | user = User( 73 | name=name, email=email, password=password, is_admin=is_admin 74 | ) 75 | 76 | self.orm.add(user) 77 | self.orm.commit() 78 | self.orm.refresh(user) 79 | 80 | return user 81 | 82 | 83 | users_orm = UsersORM() 84 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore:DeprecationWarning 4 | ignore:function ham\(\) is deprecated:DeprecationWarning -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | black==22.10.0 4 | ruff==0.0.189 5 | pytest==7.2.1 6 | httpx==0.23.3 7 | pytest-asyncio==0.20.3 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.88.0 2 | uvicorn==0.20.0 3 | sqlalchemy>=1.4 4 | pyjwt==2.6.0 5 | python-decouple==3.6 6 | passlib==1.7.4 7 | bcrypt==4.0.1 8 | psycopg2-binary==2.9.5 9 | databases[postgresql]==0.6.2 10 | pydantic[email] 11 | alembic==1.9.0 12 | aiosqlite==0.18.0 -------------------------------------------------------------------------------- /run-migrations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "running migrations commands..." 4 | 5 | python3 -m alembic revision --autogenerate -m "create inital tables" 6 | python3 -m alembic upgrade head 7 | 8 | echo "applied migrations!" -------------------------------------------------------------------------------- /schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /schemas/auth.py: -------------------------------------------------------------------------------- 1 | # Pydantic Imports 2 | from pydantic import BaseModel, EmailStr 3 | 4 | 5 | class UserLoginSchema(BaseModel): 6 | email: EmailStr 7 | password: str 8 | -------------------------------------------------------------------------------- /schemas/ledger.py: -------------------------------------------------------------------------------- 1 | # Pydantic Imports 2 | from pydantic import BaseModel, validator 3 | 4 | # FastAPI & SQLAlchemy Imports 5 | from fastapi import HTTPException 6 | from sqlalchemy.orm import Session 7 | 8 | # Core Imports 9 | from config.database import SessionLocal 10 | 11 | # Models Imports 12 | from models.ledger import Wallet as UserWallet 13 | 14 | 15 | class WalletBase(BaseModel): 16 | user: int 17 | amount: int 18 | 19 | 20 | class WalletCreate(WalletBase): 21 | title: str 22 | 23 | @validator("user", pre=True) 24 | def ensure_user_can_only_have_one_wallet(cls, value: int): 25 | """ 26 | Validation to ensure that the user can only have 10 wallets, 27 | otherwise; raise an error. `@validator("user", pre=True)` means 28 | that it will be called before the `user` field is validated 29 | 30 | :param cls: The class of the model that is being validated 31 | 32 | :param value: The value of the field being validated 33 | :type value: int 34 | 35 | :return: The value of the user_wallet_counts 36 | """ 37 | db: Session = SessionLocal() 38 | user_wallet_counts = ( 39 | db.query(UserWallet) 40 | .join(UserWallet.owner) 41 | .filter(UserWallet.user == value) 42 | .count() 43 | ) 44 | 45 | if user_wallet_counts == 10: 46 | raise HTTPException( 47 | 400, {"message": "User can only have ten wallet!"} 48 | ) 49 | return value 50 | 51 | 52 | class WalletDeposit(WalletBase): 53 | id: int 54 | 55 | 56 | class WalletWithdraw(WalletBase): 57 | id: int 58 | 59 | 60 | class Wallet2WalletTransfer(WalletBase): 61 | # this will result to: 62 | 63 | # user: int 64 | # amount: int 65 | wallet_from: int 66 | wallet_to: int 67 | 68 | 69 | class Wallet2UserWalletTransfer(Wallet2WalletTransfer): 70 | # this will result to: 71 | 72 | # user: int 73 | # amount: int 74 | # wallet_from: int 75 | # wallet_to: int 76 | user_to: int 77 | 78 | 79 | class Wallet(WalletBase): 80 | id: int 81 | title: str 82 | 83 | class Config: 84 | # Without orm_mode, if you returned a SQLAlchemy 85 | # model from your path operation, 86 | # it wouldn't include the relationship data 87 | orm_mode = True 88 | -------------------------------------------------------------------------------- /schemas/user.py: -------------------------------------------------------------------------------- 1 | # Own Imports 2 | from schemas.ledger import BaseModel, Wallet 3 | 4 | 5 | class UserBase(BaseModel): 6 | name: str 7 | email: str 8 | 9 | 10 | class UserCreate(UserBase): 11 | password: str 12 | 13 | 14 | class User(UserBase): 15 | id: int 16 | is_active: bool 17 | is_admin: bool 18 | wallets: list[Wallet] = [] 19 | 20 | class Config: 21 | orm_mode = True 22 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | import sys 3 | import os 4 | 5 | # SQLAlchemy Impprts 6 | from sqlalchemy import create_engine 7 | from sqlalchemy.orm import sessionmaker, scoped_session 8 | 9 | # Own Imports 10 | from models.user import Base 11 | 12 | 13 | # this is to include backend dir in sys.path 14 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 15 | 16 | # Initialize test database 17 | SQLALCHEMY_DATABASE_URL = "sqlite:///./test_db.sqlite" 18 | 19 | # Create Database engine 20 | engine = create_engine( 21 | SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} 22 | ) 23 | 24 | # Use connect_args parameter only with sqlite 25 | session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine) 26 | SessionTesting = scoped_session(session_factory) 27 | 28 | 29 | def create_tables(): 30 | """ 31 | This function creates all the tables in the database, 32 | then drops them. 33 | """ 34 | 35 | Base.metadata.create_all(engine) 36 | yield 37 | Base.metadata.drop_all(engine) 38 | 39 | 40 | def _get_test_db(): 41 | 42 | # Call method to create tables 43 | create_tables() 44 | 45 | session = SessionTesting() 46 | try: 47 | yield session 48 | except Exception: 49 | session.rollback() 50 | finally: 51 | session.close() 52 | -------------------------------------------------------------------------------- /tests/test_ledger.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | import json 3 | import random 4 | import string 5 | from typing import Tuple 6 | 7 | # Own Imports 8 | from orm.users import users_orm 9 | from orm.ledger import ledger_orm 10 | from tests.test_user import client 11 | 12 | # Third Party Imports 13 | import pytest 14 | 15 | 16 | name = "".join(random.choice(string.ascii_lowercase) for i in range(6)) 17 | email = name + "@email.com" 18 | password = name + "_weakpassword" 19 | 20 | wallet_title = "".join(random.choice(string.ascii_lowercase) for i in range(6)) 21 | 22 | 23 | async def create_user() -> Tuple[dict, str]: 24 | """Function to create a user.""" 25 | 26 | payload = {"name": name, "email": email, "password": password} 27 | response = client.post("/register/", data=json.dumps(payload)) 28 | return response.json(), password 29 | 30 | 31 | async def login_user(u_email: str, u_password: str): 32 | """Function to login a user and get the access token.""" 33 | 34 | payload = {"email": u_email, "password": u_password} 35 | response = client.post("/login/", data=json.dumps(payload)) 36 | return response.json()["access_token"] 37 | 38 | 39 | async def get_user_id(u_email: str) -> int: 40 | """Function to get the user id.""" 41 | 42 | user = await users_orm.get_email(u_email) 43 | return user.id 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_create_wallet(): 48 | """Ensure an authenticated user can create a wallet.""" 49 | 50 | user, u_password = await create_user() 51 | token = await login_user(user["email"], u_password) 52 | user_id = await get_user_id(user["email"]) 53 | 54 | payload = { 55 | "user": user_id, 56 | "amount": random.randint(10000, 99999), 57 | "title": wallet_title, 58 | } 59 | response = client.post( 60 | "/wallets/", 61 | data=json.dumps(payload), 62 | headers={"Authorization": "Bearer " + token}, 63 | ) 64 | 65 | assert response.status_code == 200 66 | assert response.json()["user"] == user_id 67 | assert response.json()["title"] == wallet_title 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_get_wallets(): 72 | """Ensure an authenticated user can get their wallets.""" 73 | 74 | token = await login_user(email, password) 75 | response = client.get( 76 | "/wallets/", headers={"Authorization": "Bearer " + token} 77 | ) 78 | assert response.status_code == 200 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_deposit_money(): 83 | """Ensure an authenticated user can deposit money.""" 84 | 85 | user_id = await get_user_id(email) 86 | token = await login_user(email, password) 87 | wallets = await ledger_orm.filter( 88 | **{"user_id": user_id, "skip": 0, "limit": 2} 89 | ) 90 | 91 | payload = {"user": user_id, "amount": 5000, "id": wallets[0].id} 92 | response = client.post( 93 | "/deposit/", 94 | data=json.dumps(payload), 95 | headers={"Authorization": "Bearer " + token}, 96 | ) 97 | 98 | assert response.status_code == 200 99 | assert ( 100 | response.json()["message"] 101 | == f"NGN{payload['amount']} deposit successful!" 102 | ) 103 | 104 | 105 | @pytest.mark.asyncio 106 | async def test_withdraw_money(): 107 | """Ensure an authenticated user can withdraw money.""" 108 | 109 | user_id = await get_user_id(email) 110 | token = await login_user(email, password) 111 | wallets = await ledger_orm.filter( 112 | **{"user_id": user_id, "skip": 0, "limit": 2} 113 | ) 114 | 115 | payload = {"user": user_id, "amount": 5000, "id": wallets[0].id} 116 | response = client.post( 117 | "/withdraw/", 118 | data=json.dumps(payload), 119 | headers={"Authorization": "Bearer " + token}, 120 | ) 121 | 122 | assert response.status_code == 200 123 | assert ( 124 | response.json()["message"] 125 | == f"NGN{payload['amount']} withdrawn successful!" 126 | ) 127 | 128 | 129 | @pytest.mark.asyncio 130 | async def test_wallet_to_wallet_transfer(): 131 | """Ensure an authenticated user can transfer from x to y wallet.""" 132 | 133 | user_id = await get_user_id(email) 134 | token = await login_user(email, password) 135 | 136 | wallet_from = client.post( 137 | "/wallets/", 138 | data=json.dumps( 139 | { 140 | "user": user_id, 141 | "amount": random.randint(10000, 99999), 142 | "title": wallet_title, 143 | } 144 | ), 145 | headers={"Authorization": "Bearer " + token}, 146 | ) 147 | wallet_to = client.post( 148 | "/wallets/", 149 | data=json.dumps( 150 | { 151 | "user": user_id, 152 | "amount": random.randint(10000, 99999), 153 | "title": wallet_title + "_2", 154 | } 155 | ), 156 | headers={"Authorization": "Bearer " + token}, 157 | ) 158 | 159 | payload = { 160 | "user": user_id, 161 | "amount": 50000, 162 | "wallet_from": wallet_from.json()["id"], 163 | "wallet_to": wallet_to.json()["id"], 164 | } 165 | response = client.post( 166 | "/transfer/wallet-to-wallet/", 167 | data=json.dumps(payload), 168 | headers={"Authorization": "Bearer " + token}, 169 | ) 170 | 171 | assert response.status_code == 200 172 | assert ( 173 | response.json()["message"] 174 | == f"NGN{payload['amount']} was transfered from \ 175 | W#{wallet_from.json()['id']} wallet to W#{wallet_to.json()['id']} wallet!" 176 | ) 177 | 178 | 179 | @pytest.mark.asyncio 180 | async def test_wallet_to_user_transfer(): 181 | """Ensure an authenticated user can transfer their account to 182 | another user's account.""" 183 | 184 | user_id = await get_user_id(email) 185 | token = await login_user(email, password) 186 | 187 | r_user_name = "".join( 188 | random.choice(string.ascii_lowercase) for i in range(6) 189 | ) 190 | r_user_email = r_user_name + "@email.com" 191 | r_user_password = r_user_name + "_weakpassword" 192 | 193 | receiving_user = client.post( 194 | "/register/", 195 | data=json.dumps( 196 | { 197 | "name": r_user_name, 198 | "email": r_user_email, 199 | "password": r_user_password, 200 | } 201 | ), 202 | ) 203 | receiving_user_token = await login_user(r_user_email, r_user_password) 204 | 205 | wallet_from = client.post( 206 | "/wallets/", 207 | data=json.dumps( 208 | { 209 | "user": user_id, 210 | "amount": random.randint(10000, 99999), 211 | "title": wallet_title, 212 | } 213 | ), 214 | headers={"Authorization": "Bearer " + token}, 215 | ) 216 | wallet_to = client.post( 217 | "/wallets/", 218 | data=json.dumps( 219 | { 220 | "user": receiving_user.json()["id"], 221 | "amount": random.randint(10000, 99999), 222 | "title": wallet_title + "_2", 223 | } 224 | ), 225 | headers={"Authorization": "Bearer " + receiving_user_token}, 226 | ) 227 | 228 | payload = { 229 | "user": user_id, 230 | "amount": 3000, 231 | "wallet_from": wallet_from.json()["id"], 232 | "wallet_to": wallet_to.json()["id"], 233 | "user_to": receiving_user.json()["id"], 234 | } 235 | response = client.post( 236 | "/transfer/wallet-to-user/", 237 | data=json.dumps(payload), 238 | headers={"Authorization": "Bearer " + token}, 239 | ) 240 | 241 | assert response.status_code == 200 242 | assert wallet_from.status_code == 200 243 | assert wallet_to.status_code == 200 244 | 245 | assert ( 246 | response.json()["message"] 247 | == f"Transferred NGN{payload['amount']} \ 248 | to U#{receiving_user.json()['id']} W#{wallet_to.json()['id']} wallet." 249 | ) 250 | assert wallet_from.json()["user"] == user_id 251 | assert wallet_from.json()["title"] == wallet_title 252 | assert wallet_to.json()["user"] == receiving_user.json()["id"] 253 | assert wallet_to.json()["title"] == wallet_title + "_2" 254 | 255 | 256 | @pytest.mark.asyncio 257 | async def test_total_wallet_balance(): 258 | """Ensure an authenticated user can get the total balance of their wallets.""" 259 | 260 | token = await login_user(email, password) 261 | response = client.get( 262 | "/balance/", headers={"Authorization": "Bearer " + token} 263 | ) 264 | 265 | assert response.status_code == 200 266 | 267 | 268 | @pytest.mark.asyncio 269 | async def test_wallet_balance(): 270 | """Ensure an authenticated user can get the balance of a particular wallet.""" 271 | 272 | token = await login_user(email, password) 273 | user_id = await get_user_id(email) 274 | wallets = await ledger_orm.filter( 275 | **{"user_id": user_id, "skip": 0, "limit": 2} 276 | ) 277 | 278 | response = client.get( 279 | "/balance/", 280 | params={"wallet_id": wallets[0].id}, 281 | headers={"Authorization": "Bearer " + token}, 282 | ) 283 | 284 | assert response.status_code == 200 285 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | # Stdlib Imports 2 | import json 3 | import random 4 | import string 5 | 6 | # FastAPI Imports 7 | from fastapi.testclient import TestClient 8 | 9 | # Own Imports 10 | from main import app 11 | from orm.users import users_orm 12 | from auth.hashers import pwd_hasher 13 | 14 | # Third Party Imports 15 | import pytest 16 | 17 | 18 | # initialize test client 19 | client = TestClient(app) 20 | 21 | 22 | name = "".join(random.choice(string.ascii_lowercase) for i in range(6)) 23 | email = name + "@email.com" 24 | password = name + "_weakpassword" 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_create_user(): 29 | """Ensure a user can register.""" 30 | 31 | payload = { 32 | "name": name, 33 | "email": email, 34 | "password": password, 35 | } 36 | response = client.post("/register/", data=json.dumps(payload)) 37 | 38 | assert response.status_code == 200 39 | assert response.json()["email"] == email 40 | assert response.json()["is_active"] == True 41 | assert response.json()["wallets"] == [] 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_login_user_success(): 46 | """Ensure a user can login successfully.""" 47 | 48 | payload = {"email": email, "password": password} 49 | response = client.post("/login/", data=json.dumps(payload)) 50 | 51 | assert response.status_code == 200 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_login_user_password_incorrect(): 56 | """Ensure a user with incorrect password can not login.""" 57 | 58 | payload = {"email": email, "password": "string"} 59 | response = client.post("/login/", data=json.dumps(payload)) 60 | 61 | assert response.status_code == 401 62 | assert response.json()["detail"]["message"] == "Password incorrect!" 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_login_user_password_not_exist(): 67 | """Ensure a user that has no account can not login.""" 68 | 69 | payload = {"email": "user@example.com", "password": "string"} 70 | response = client.post("/login/", data=json.dumps(payload)) 71 | 72 | assert response.status_code == 404 73 | assert response.json()["detail"]["message"] == "User does not exist!" 74 | 75 | 76 | @pytest.mark.asyncio 77 | async def test_users_info(): 78 | """Ensure an authenticated user with admin priviledges can get a list of users info.""" 79 | 80 | # set up fake credentials for admin user 81 | admin_name = "".join( 82 | random.choice(string.ascii_lowercase) for i in range(8) 83 | ) 84 | admin_email = admin_name + "@admin.com" 85 | admin_password = admin_name + "_weakpassword" 86 | admin_hashed_password = pwd_hasher.hash_password( 87 | admin_name + "_weakpassword" 88 | ) 89 | 90 | # create fake admin user 91 | admin_user = await users_orm.create_admin( 92 | admin_name, admin_email, admin_hashed_password, True 93 | ) 94 | 95 | login_response = client.post( 96 | "/login/", 97 | data=json.dumps( 98 | {"email": admin_user.email, "password": admin_password} 99 | ), 100 | ) 101 | token = login_response.json()["access_token"] 102 | 103 | response = client.get( 104 | "/users/", headers={"Authorization": "Bearer " + token} 105 | ) 106 | 107 | assert response.status_code == 200 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_user_info(): 112 | """Ensure an authenticated user can get their info.""" 113 | 114 | login_response = client.post( 115 | "/login/", data=json.dumps({"email": email, "password": password}) 116 | ) 117 | token = login_response.json()["access_token"] 118 | 119 | response = client.get( 120 | "/users/me/", headers={"Authorization": "Bearer " + token} 121 | ) 122 | 123 | assert response.status_code == 200 124 | assert response.json()["email"] == email 125 | assert response.json()["is_active"] == True 126 | -------------------------------------------------------------------------------- /users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /users/api.py: -------------------------------------------------------------------------------- 1 | # FastAPI Imports 2 | from fastapi import Depends, HTTPException 3 | 4 | # Own Imports 5 | from schemas.user import User 6 | from users.router import router 7 | from orm.users import users_orm 8 | from core.deps import get_current_user, get_admin_user 9 | 10 | 11 | @router.get("/users/", response_model=list[User]) 12 | async def users_info( 13 | skip: int = 0, limit: int = 100, admin_user: User = Depends(get_admin_user) 14 | ): 15 | 16 | if admin_user is None: 17 | raise HTTPException(404, {"message": "Admin user does not exist!"}) 18 | 19 | users = await users_orm.list(skip, limit) 20 | return users 21 | 22 | 23 | @router.get("/users/me/", response_model=User) 24 | async def user_info(current_user: User = Depends(get_current_user)): 25 | 26 | user = await users_orm.get(user_id=current_user.id) 27 | return user 28 | -------------------------------------------------------------------------------- /users/auth.py: -------------------------------------------------------------------------------- 1 | # FastAPI Imports 2 | from fastapi import HTTPException 3 | 4 | # Own Imports 5 | from orm.users import users_orm 6 | from users.router import router 7 | from users.services import create_user 8 | from auth.auth_handler import authentication 9 | from auth.hashers import pwd_hasher 10 | from schemas.user import User, UserCreate 11 | from schemas.auth import UserLoginSchema 12 | 13 | 14 | # Remove dependencies 15 | router.dependencies.clear() 16 | 17 | 18 | @router.post("/register/", response_model=User) 19 | async def create_new_user(new_user: UserCreate): 20 | user = await users_orm.get_email(new_user.email) 21 | 22 | if user: 23 | raise HTTPException(400, {"message": "User already exists!"}) 24 | return await create_user(new_user) 25 | 26 | 27 | @router.post("/login/") 28 | async def login_user(authenticate: UserLoginSchema): 29 | user = await users_orm.get_email(authenticate.email) 30 | 31 | if user: 32 | user_token = authentication.sign_jwt(user.id) 33 | 34 | if pwd_hasher.check_password(authenticate.password, user.password): 35 | return user_token 36 | 37 | raise HTTPException(401, {"message": "Password incorrect!"}) 38 | raise HTTPException(404, {"message": "User does not exist!"}) 39 | -------------------------------------------------------------------------------- /users/router.py: -------------------------------------------------------------------------------- 1 | # FastAPI Imports 2 | from fastapi import APIRouter, Depends 3 | 4 | # Own Imports 5 | from auth.auth_bearer import jwt_bearer 6 | 7 | 8 | # initialize router 9 | router = APIRouter(dependencies=[Depends(jwt_bearer)]) 10 | -------------------------------------------------------------------------------- /users/services.py: -------------------------------------------------------------------------------- 1 | # Own Imports 2 | from auth.hashers import pwd_hasher 3 | from orm.users import users_orm 4 | from schemas.user import UserCreate 5 | from models.user import User 6 | 7 | 8 | async def create_user(user: UserCreate) -> User: 9 | """ 10 | This function creates a new user in the database. 11 | 12 | :param user: schemas.UserCreate 13 | :type user: schemas.UserCreate 14 | 15 | :return: The user object 16 | """ 17 | 18 | hashed_password = pwd_hasher.hash_password(user.password) 19 | user = await users_orm.create(user, hashed_password) 20 | return user 21 | --------------------------------------------------------------------------------