diff --git a/server/.vscode/launch.json b/server/.vscode/launch.json index d9be6a0..195ea05 100644 --- a/server/.vscode/launch.json +++ b/server/.vscode/launch.json @@ -11,7 +11,8 @@ "module": "flask", "env": { "FLASK_APP": "server.py", - "FLASK_ENV": "development" + "FLASK_ENV": "development", + "FLASK_RUN_PORT": "5001" }, "args": [ "run", diff --git a/server/foxbank_server/apis/__init__.py b/server/foxbank_server/apis/__init__.py index e1d307b..ab0f6f8 100644 --- a/server/foxbank_server/apis/__init__.py +++ b/server/foxbank_server/apis/__init__.py @@ -3,6 +3,8 @@ from flask_smorest import Api from .accounts import bp as acc_bp from .login import bp as login_bp +from .transactions import bp as transactions_bp +from .notifications import bp as notifications_bp class ApiWithErr(Api): def handle_http_exception(self, error): @@ -27,3 +29,5 @@ def init_apis(app: Flask): }) api.register_blueprint(login_bp, url_prefix='/login') api.register_blueprint(acc_bp, url_prefix='/accounts') + api.register_blueprint(transactions_bp, url_prefix='/transactions') + api.register_blueprint(notifications_bp, url_prefix='/notifications') diff --git a/server/foxbank_server/apis/accounts.py b/server/foxbank_server/apis/accounts.py index 9b4017e..b08cb51 100644 --- a/server/foxbank_server/apis/accounts.py +++ b/server/foxbank_server/apis/accounts.py @@ -2,8 +2,12 @@ from http import HTTPStatus from flask.views import MethodView from flask_smorest import Blueprint, abort from marshmallow import Schema, fields + +import re + from ..decorators import ensure_logged_in from ..models import Account +from ..utils.iban import IBAN_BANKS, check_iban from .. import decorators from .. import db_utils from .. import returns @@ -13,14 +17,20 @@ bp = Blueprint('accounts', __name__, description='Bank Accounts operations') VALID_CURRENCIES = ['RON', 'EUR', 'USD'] ACCOUNT_TYPES = ['Checking', 'Savings'] -class MetaCurrenciesSchema(Schema): - status = fields.Constant('success') +class MetaCurrenciesSchema(returns.SuccessSchema): currencies = fields.List(fields.Str()) -class MetaAccountTypesSchema(Schema): - status = fields.Constant('success') +class MetaAccountTypesSchema(returns.SuccessSchema): account_types = fields.List(fields.Str(), data_key='accountTypes') +class MetaValidateIbanParams(Schema): + iban = fields.Str(example='RO15RZBR0000060021338765') + +class MetaValidateIbanSchema(returns.SuccessSchema): + valid = fields.Bool() + formatted_iban = fields.Str(data_key='formattedIban', optional=True) + bank_name = fields.Str(data_key='bankName', optional=True, description='Known bank for IBAN') + @bp.get('/meta/currencies') @bp.response(200, MetaCurrenciesSchema) def get_valid_currencies(): @@ -35,13 +45,32 @@ def get_valid_account_types(): return returns.success(account_types=ACCOUNT_TYPES) +@bp.get('/meta/validate_iban') +@bp.arguments(MetaValidateIbanParams, location='query', as_kwargs=True) +@bp.response(200, MetaValidateIbanSchema) +def get_validate_iban(iban: str): + """Validate IBAN""" + iban = re.sub(r'\s', '', iban) + valid = len(iban) > 8 and re.match(r'^[A-Z]{2}[0-9]{2}', iban) is not None and check_iban(iban) + bank_name = None + if iban[0:2] in IBAN_BANKS: + if iban[4:8] in IBAN_BANKS[iban[0:2]]: + bank_name = IBAN_BANKS[iban[0:2]][iban[4:8]] + + return returns.success( + valid=valid, + formatted_iban=re.sub(r'(.{4})', r'\1 ', iban).strip() if valid else None, + bank_name=bank_name if valid else None, + ) + + class AccountResponseSchema(returns.SuccessSchema): - account = fields.Nested(Account.Schema) + account = fields.Nested(Account.AccountSchema) @bp.get('/') @ensure_logged_in -@bp.response(401, returns.ErrorSchema, description='Login failure') +@bp.response(401, returns.ErrorSchema, description='Login failure or not allowed') @bp.doc(security=[{'Token': []}]) @bp.response(200, AccountResponseSchema) def get_account_id(account_id: int): @@ -51,7 +80,7 @@ def get_account_id(account_id: int): return returns.abort(returns.NOT_FOUND) if decorators.user_id != db_utils.whose_account(account): return returns.abort(returns.UNAUTHORIZED) - account = account.to_json() + # account = account.to_json() return returns.success(account=account) @@ -67,26 +96,26 @@ def get_account_iban(iban: str): return returns.abort(returns.NOT_FOUND) if decorators.user_id != db_utils.whose_account(account): return returns.abort(returns.UNAUTHORIZED) - account = account.to_json() + # account = account.to_json() return returns.success(account=account) @bp.route('/') class AccountsList(MethodView): class CreateAccountParams(Schema): - currency = fields.String() - account_type = fields.String(data_key='accountType') - custom_name = fields.String(data_key='customName') + currency = fields.String(example='RON') + account_type = fields.String(data_key='accountType', example='Checking') + custom_name = fields.String(data_key='customName', example='Daily Spending') class CreateAccountResponseSchema(returns.SuccessSchema): - account = fields.Nested(Account.Schema) + account = fields.Nested(Account.AccountSchema) @ensure_logged_in @bp.response(401, returns.ErrorSchema, description='Login failure') @bp.doc(security=[{'Token': []}]) @bp.arguments(CreateAccountParams, as_kwargs=True) @bp.response(200, CreateAccountResponseSchema) - @bp.response(HTTPStatus.UNPROCESSABLE_ENTITY, description='Invalid currency or account type') + @bp.response(422, returns.ErrorSchema, description='Invalid currency or account type') def post(self, currency: str, account_type: str, custom_name: str): """Create account""" if currency not in VALID_CURRENCIES: @@ -96,10 +125,11 @@ class AccountsList(MethodView): account = Account(-1, '', currency, account_type, custom_name or '') db_utils.insert_account(decorators.user_id, account) - return returns.success(account=account.to_json()) + # return returns.success(account=account.to_json()) + return returns.success(account=account) class AccountsResponseSchema(returns.SuccessSchema): - accounts = fields.List(fields.Nested(Account.Schema)) + accounts = fields.List(fields.Nested(Account.AccountSchema)) @ensure_logged_in @bp.response(401, returns.ErrorSchema, description='Login failure') diff --git a/server/foxbank_server/apis/login.py b/server/foxbank_server/apis/login.py index 97ca243..a4aac3b 100644 --- a/server/foxbank_server/apis/login.py +++ b/server/foxbank_server/apis/login.py @@ -67,7 +67,7 @@ class WhoAmI(MethodView): def get(self): """Get information about currently logged in user""" user: User | None = get_user(user_id=decorators.user_id) - if user is not None: - user = user.to_json() + # if user is not None: + # user = user.to_json() return returns.success(user=user) diff --git a/server/foxbank_server/apis/notifications.py b/server/foxbank_server/apis/notifications.py new file mode 100644 index 0000000..ab31612 --- /dev/null +++ b/server/foxbank_server/apis/notifications.py @@ -0,0 +1,60 @@ +from datetime import datetime +from flask.views import MethodView +from flask_smorest import Blueprint +from marshmallow import Schema, fields + +from ..db_utils import get_notifications, insert_notification, mark_notification_as_read, whose_notification +from ..decorators import ensure_logged_in +from ..models import Notification +from .. import decorators, returns + +bp = Blueprint('notifications', __name__, description='Notifications operations') + +@bp.post('//mark_read') +@ensure_logged_in +@bp.response(401, returns.ErrorSchema, description='Login failure or not allowed') +@bp.doc(security=[{'Token': []}]) +@bp.response(201, description='Successfully marked as read') +def mark_as_read(notification_id: int): + """Mark notification as read""" + if decorators.user_id != whose_notification(notification_id): + return returns.abort(returns.UNAUTHORIZED) + mark_notification_as_read(notification_id) + + +@bp.route('/') +class NotificationsList(MethodView): + class NotificationsListPostParams(Schema): + body = fields.Str(description='Text of the notification') + read = fields.Bool(default=False, description='Whether the notification was read or not') + + class NotificationsListPostSchema(returns.SuccessSchema): + notification = fields.Nested(Notification.NotificationSchema) + + @ensure_logged_in + @bp.response(401, returns.ErrorSchema, description='Login failure') + @bp.doc(security=[{'Token': []}]) + @bp.arguments(NotificationsListPostParams, as_kwargs=True) + @bp.response(200, NotificationsListPostSchema) + def post(self, body: str, read: bool = False): + """Post a notification to the currently logged in user + + The usefulness of this endpoint is questionable besides debugging since it's a notification to self + """ + now = datetime.now() + notification = Notification.new_notification(body, now, read) + insert_notification(decorators.user_id, notification) + return returns.success(notification=notification) + + class NotificationsListGetSchema(returns.SuccessSchema): + notifications = fields.List(fields.Nested(Notification.NotificationSchema)) + + @ensure_logged_in + @bp.response(401, returns.ErrorSchema, description='Login failure') + @bp.doc(security=[{'Token': []}]) + @bp.response(200, NotificationsListGetSchema) + def get(self): + """Get all notifications for current user""" + notifications = get_notifications(decorators.user_id) + + return returns.success(notifications=notifications) diff --git a/server/foxbank_server/apis/transactions.py b/server/foxbank_server/apis/transactions.py new file mode 100644 index 0000000..b41aff7 --- /dev/null +++ b/server/foxbank_server/apis/transactions.py @@ -0,0 +1,122 @@ +from datetime import date, datetime +from flask.views import MethodView +from flask_smorest import Blueprint +from marshmallow import Schema, fields + +import re + +from ..decorators import ensure_logged_in +from ..db_utils import get_transactions, get_account, get_accounts, insert_transaction, whose_account, insert_notification +from ..models import Account, Notification, Transaction +from ..utils.iban import check_iban +from .. import decorators, returns + +bp = Blueprint('transactions', __name__, description='Bank transfers and other transactions') + +@bp.route('/') +class TransactionsList(MethodView): + class TransactionsParams(Schema): + account_id = fields.Int(min=1) + + class TransactionsGetResponse(returns.SuccessSchema): + transactions = fields.List(fields.Nested(Transaction.TransactionSchema)) + + @ensure_logged_in + @bp.response(401, returns.ErrorSchema, description='Login failure or not allowed') + @bp.doc(security=[{'Token': []}]) + @bp.arguments(TransactionsParams, as_kwargs=True, location='query') + @bp.response(200, TransactionsGetResponse) + def get(self, account_id: int): + """Get transactions for a certain account""" + if whose_account(account_id) != decorators.user_id: + return returns.abort(returns.UNAUTHORIZED) + + # return returns.success( + # transactions=[t.to_json() for t in get_transactions(account_id)] + # ) + return returns.success( + transactions=get_transactions(account_id) + ) + + class TransactionsCreateParams(Schema): + account_id = fields.Int(min=1) + destination_iban = fields.Str() + amount = fields.Int(min=1) + description = fields.Str(default='') + + class TransactionsCreateResponse(returns.SuccessSchema): + transaction = fields.Nested(Transaction.TransactionSchema) + + @ensure_logged_in + @bp.response(401, returns.ErrorSchema, description='Login failure or not allowed') + @bp.response(404, returns.ErrorSchema, description='Destination account not found') + @bp.response(422, returns.ErrorSchema, description='Invalid account') + @bp.doc(security=[{'Token': []}]) + @bp.arguments(TransactionsCreateParams, as_kwargs=True) + @bp.response(200, TransactionsCreateResponse) + def post(self, account_id: int, destination_iban: str, amount: int, description: str = ''): + """Create a send_transfer transaction""" + if whose_account(account_id) != decorators.user_id: + return returns.abort(returns.UNAUTHORIZED) + + account: Account = get_account(account_id=account_id) + + if account is None: + return returns.abort(returns.invalid_argument('account_id')) + + amount = -1 * abs(amount) + + if account.balance + amount < 0: + return returns.abort(returns.NO_BALANCE) + + # Check if IBAN is valid + destination_iban = re.sub(r'\s', '', destination_iban) + + if not check_iban(destination_iban): + return returns.abort(returns.INVALID_IBAN) + + date = datetime.now() + + # Check if transaction is to another FoxBank account + reverse_transaction = None + if destination_iban[4:8] == 'FOXB': + for acc in get_accounts(): + if destination_iban == acc.iban: + reverse_transaction = Transaction.new_transaction( + date_time=date, + transaction_type='receive_transfer', + status='processed', + other_party={'iban': account.iban,}, + extra={ + 'currency': account.currency, + 'amount': -amount, + 'description': description, + }, + ) + insert_transaction(acc.id, reverse_transaction) + formatted_iban = re.sub(r'(.{4})', r'\1 ', account.iban).strip() + notification = Notification.new_notification( + body=f'Transfer of {-amount // 100}.{-amount % 100:0>2} {account.currency} received from {formatted_iban} in your {acc.custom_name or acc.account_type} account.', + date_time=date, + read=False, + ) + insert_notification(acc.id, notification) + break + else: + return returns.abort(returns.NOT_FOUND) + + transaction = Transaction.new_transaction( + date_time=date, + transaction_type='send_transfer', + status=('processed' if reverse_transaction is not None else 'pending'), + other_party={'iban': destination_iban,}, + extra={ + 'currency': account.currency, + 'amount': amount, + 'description': description, + }, + ) + + insert_transaction(account_id, transaction) + + return returns.success(transaction=transaction) diff --git a/server/foxbank_server/db_utils.py b/server/foxbank_server/db_utils.py index 3170413..7f02a4d 100644 --- a/server/foxbank_server/db_utils.py +++ b/server/foxbank_server/db_utils.py @@ -1,4 +1,5 @@ from functools import wraps +import json import sys from types import ModuleType @@ -85,7 +86,19 @@ class Module(ModuleType): ''', (user_id,)) else: cur.execute('select id, iban, currency, account_type, custom_name from accounts') - return [models.Account.from_query(q) for q in cur.fetchall()] + accounts = [models.Account.from_query(q) for q in cur.fetchall()] + + for account in accounts: + cur.execute( + 'select balance from V_account_balance where account_id = ?', + (account.id,), + ) + + result = cur.fetchone() + if result is not None: + account.balance = result['balance'] + + return accounts @get_db @@ -106,7 +119,18 @@ class Module(ModuleType): result = cur.fetchone() if result is None: return None - return models.Account.from_query(result) + account = models.Account.from_query(result) + + cur.execute( + 'select balance from V_account_balance where account_id = ?', + (account.id,), + ) + + result = cur.fetchone() + if result is not None: + account.balance = result['balance'] + + return account @get_db @@ -158,4 +182,129 @@ class Module(ModuleType): self.db.commit() + @get_db + def get_transactions(self, account_id: int) -> list[models.Transaction]: + cur = self.db.cursor() + cur.execute( + 'select transaction_id from accounts_transactions where account_id = ?', + (account_id,), + ) + + transactions = [] + for tid in (row['transaction_id'] for row in cur.fetchall()): + cur.execute( + 'select * from transactions where id = ?', + (tid,), + ) + + db_res = cur.fetchone() + if db_res is None: + continue + transactions.append(models.Transaction.from_query(db_res)) + + return transactions + + @get_db + def insert_transaction(self, account_id: int, transaction: models.Transaction): + cur = self.db.cursor() + cur.execute( + 'insert into transactions(datetime, other_party, status, type, extra) values (?, ?, ?, ?, ?)', + ( + transaction.date_time.isoformat(), + json.dumps(transaction.other_party), + transaction.status, + transaction.transaction_type, + json.dumps(transaction.extra), + ), + ) + + cur.execute( + 'select id from transactions where datetime = ? and other_party = ? and status = ? and type = ? and extra = ?', + ( + transaction.date_time.isoformat(), + json.dumps(transaction.other_party), + transaction.status, + transaction.transaction_type, + json.dumps(transaction.extra), + ), + ) + transaction.id = cur.fetchone()['id'] + + cur.execute( + 'insert into accounts_transactions(account_id, transaction_id) values (?, ?)', + (account_id, transaction.id), + ) + + self.db.commit() + + @get_db + def get_notifications(self, user_id: int) -> list[models.Notification]: + cur = self.db.cursor() + + cur.execute( + ''' + select n.id, n.body, n.datetime, n.read + from notifications as n + inner join users_notifications on n.id = users_notifications.notification_id + where users_notifications.user_id = ? + ''', + (user_id,), + ) + + return [models.Notification.from_query(q) for q in cur.fetchall()] + + @get_db + def insert_notification(self, user_id: int, notification: models.Notification): + cur = self.db.cursor() + + cur.execute( + 'insert into notifications(body, datetime, read) values (?, ?, ?)', + ( + notification.body, + notification.date_time.isoformat(), + 1 if notification.read else 0, + ), + ) + + cur.execute( + 'select id from notifications where body = ? and datetime = ? and read = ?', + ( + notification.body, + notification.date_time.isoformat(), + 1 if notification.read else 0, + ), + ) + notification.id = cur.fetchone()['id'] + + cur.execute( + 'insert into users_notifications values (?, ?)', + (user_id, notification.id,), + ) + + self.db.commit() + + @get_db + def whose_notification(self, notification: int | models.Notification) -> int | None: + try: + notification_id = notification.id + except AttributeError: + notification_id = notification + + cur = self.db.cursor() + cur.execute('select user_id from users_notifications where notification_id = ?', (notification_id,)) + result = cur.fetchone() + if not result: + return None + return result[0] + + @get_db + def mark_notification_as_read(self, notification_id: int): + cur = self.db.cursor() + cur.execute( + 'update notifications set read = 1 where id = ?', + (notification_id,), + ) + self.db.commit() + + sys.modules[__name__] = Module(__name__) diff --git a/server/foxbank_server/models.py b/server/foxbank_server/models.py index 0e01f7a..65ec9c9 100644 --- a/server/foxbank_server/models.py +++ b/server/foxbank_server/models.py @@ -1,5 +1,6 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from marshmallow import Schema, fields +from datetime import datetime @dataclass class User: @@ -50,13 +51,15 @@ class Account: currency: str account_type: str custom_name: str + balance: int = field(default=0) - class Schema(Schema): + class AccountSchema(Schema): id = fields.Int(required=False) iban = fields.Str() currency = fields.Str() account_type = fields.Str(data_key='accountType') custom_name = fields.Str(data_key='customName') + balance = fields.Int() @staticmethod def new_account(currency: str, account_type: str, custom_name: str = '') -> 'Account': @@ -74,6 +77,7 @@ class Account: 'currency': self.currency, 'accountType': self.account_type, 'customName': self.custom_name, + 'balance': self.balance, } if include_id: result['id'] = self.id @@ -82,3 +86,90 @@ class Account: @classmethod def from_query(cls, query_result): return cls(*query_result) + + +@dataclass +class Transaction: + id: int + date_time: datetime + other_party: str + status: str + transaction_type: str + extra: str + + class TransactionSchema(Schema): + id = fields.Int(required=False) + date_time = fields.DateTime(data_key='datetime') + other_party = fields.Dict(keys=fields.Str(), values=fields.Raw(), data_key='otherParty') + status = fields.Str() + transaction_type = fields.Str(data_key='transactionType') + extra = fields.Dict(keys=fields.Str(), values=fields.Raw()) + + @staticmethod + def new_transaction(date_time: datetime, other_party: str, status: str, transaction_type: str, extra: str = '') -> 'Transaction': + return Transaction( + id=-1, + date_time=date_time, + other_party=other_party, + status=status, + transaction_type=transaction_type, + extra=extra, + ) + + def to_json(self, include_id=True): + result = { + 'datetime': self.date_time.isoformat(), + 'otherParty': self.other_party, + 'status': self.status, + 'transactionType': self.transaction_type, + 'extra': self.extra, + } + if include_id: + result['id'] = self.id + return result + + @classmethod + def from_query(cls, query_result): + import json + + query_result = list(query_result) + if type(query_result[1]) is str: + query_result[1] = datetime.fromisoformat(query_result[1]) + if type(query_result[2]) is str: + query_result[2] = json.loads(query_result[2]) + if type(query_result[5]) is str: + query_result[5] = json.loads(query_result[5]) + + return cls(*query_result) + +@dataclass +class Notification: + id: int + body: str + date_time: datetime + read: bool + + class NotificationSchema(Schema): + id = fields.Int(required=False) + body = fields.Str() + date_time = fields.DateTime(data_key='datetime') + read = fields.Bool() + + @staticmethod + def new_notification(body: str, date_time: datetime, read: bool = False) -> 'Notification': + return Notification( + id=-1, + body=body, + date_time=date_time, + read=read, + ) + + @classmethod + def from_query(cls, query_result): + query_result = list(query_result) + if type(query_result[2]) is str: + query_result[2] = datetime.fromisoformat(query_result[2]) + if type(query_result[3]) is not bool: + query_result[3] = bool(query_result[3]) + + return cls(*query_result) diff --git a/server/foxbank_server/returns.py b/server/foxbank_server/returns.py index f78f852..38f5cae 100644 --- a/server/foxbank_server/returns.py +++ b/server/foxbank_server/returns.py @@ -61,6 +61,20 @@ UNAUTHORIZED = _make_error( "You are logged in but the resource you're trying to access isn't available to you", ) +# Transactions + +NO_BALANCE = _make_error( + _HTTPStatus.BAD_REQUEST, + 'transaction/no_balance', + 'Not enough balance to make the transaction', +) + +INVALID_IBAN = _make_error( + _HTTPStatus.BAD_REQUEST, + 'transaction/invalid_iban', + 'Recipient IBAN is invalid', +) + # Success @@ -74,15 +88,15 @@ def success(http_status: Any = _HTTPStatus.OK, /, **kargs): # Schemas -from marshmallow import Schema, fields +from marshmallow import Schema, fields, validate class ErrorSchema(Schema): - status = fields.Constant('error') + status = fields.Str(default='error', validate=validate.Equal('error')) code = fields.Str() message = fields.Str(required=False) class SuccessSchema(Schema): - status = fields.Constant('success') + status = fields.Str(default='success', validate=validate.Equal('success')) # smorest diff --git a/server/foxbank_server/utils/iban.py b/server/foxbank_server/utils/iban.py index f0dec40..4af4c7a 100644 --- a/server/foxbank_server/utils/iban.py +++ b/server/foxbank_server/utils/iban.py @@ -1,6 +1,30 @@ from .string import str_range_replace +IBAN_BANKS = { + 'RO': { + 'NBOR': 'BANCA NATIONALA A ROMANIEI', + 'BUCU': 'ALPHA BANK ROMANIA SA', + 'CARP': 'BANCA COMERCIALA CARPATICA SA', + 'RNCB': 'BANCA COMERCIALA ROMANA SA', + 'BRDE': 'BANCA ROMANA PENTRU DEZVOLTARE', + 'BRMA': 'BANCA ROMANEASCA SA', + 'BTRL': 'BANCA TRANSILVANIA SA', + 'DAFB': 'BANK LEUMI ROMANIA SA', + 'CECE': 'CASA DE ECONOMII SI CONSEMNATIUNI CEC SA', + 'CITI': 'CITIBANK ROMANIA SA', + 'UGBI': 'GARANTIBANK INTERNATIONAL NV - SUCURSALA ROMANIA', + 'INGB': 'ING BANK NV', + 'BREL': 'LIBRA BANK SA', + 'BNRB': 'OTP BANK ROMANIA SA', + 'RZBR': 'RAIFFEISEN BANK SA', + 'TREZ': 'TREZORERIA STATULUI', + 'BACX': 'UNICREDIT BANK SA', + 'FOXB': 'FOXBANK', + }, +} + + def c_to_iban_i(c: str) -> int: a = ord(c) if a in range(48, 58): diff --git a/server/init.sql b/server/init.sql index c241a9d..7643ad3 100644 --- a/server/init.sql +++ b/server/init.sql @@ -58,4 +58,12 @@ create table users_notifications ( notification_id integer UNIQUE not null, foreign key (user_id) references users (id), foreign key (notification_id) references notifications (id) -); \ No newline at end of file +); + +create view V_account_balance as +select + accounts_transactions.account_id as "account_id", + sum(json_extract(transactions.extra, '$.amount')) as "balance" +from transactions +inner join accounts_transactions on accounts_transactions.transaction_id = transactions.id +group by accounts_transactions.account_id;