diff --git a/server/foxbank_server/apis/accounts.py b/server/foxbank_server/apis/accounts.py index 920f7d6..b41e101 100644 --- a/server/foxbank_server/apis/accounts.py +++ b/server/foxbank_server/apis/accounts.py @@ -49,7 +49,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) @@ -65,7 +65,7 @@ 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) @@ -84,7 +84,7 @@ class AccountsList(MethodView): @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: @@ -94,7 +94,8 @@ 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.AccountSchema)) 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/transactions.py b/server/foxbank_server/apis/transactions.py index 7e29624..72e5376 100644 --- a/server/foxbank_server/apis/transactions.py +++ b/server/foxbank_server/apis/transactions.py @@ -1,11 +1,15 @@ +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 ..models import Transaction -from ..db_utils import get_transactions -from .. import returns +from ..db_utils import get_transactions, get_account, get_accounts, insert_transaction, whose_account +from ..models import Account, Transaction +from ..utils.iban import check_iban +from .. import decorators, returns bp = Blueprint('transactions', __name__, description='Bank transfers and other transactions') @@ -18,29 +22,90 @@ class TransactionsList(MethodView): transactions = fields.List(fields.Nested(Transaction.TransactionSchema)) @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.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=[t.to_json() for t in get_transactions(account_id)] + 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') + @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) + @bp.arguments(TransactionsCreateParams, as_kwargs=True) @bp.response(200, TransactionsCreateResponse) - def post(self, account_id: int, destination_iban: str, amount: int): + def post(self, account_id: int, destination_iban: str, amount: int, description: str = ''): """Create a send_transfer transaction""" - raise NotImplementedError() + 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,}, + ) + insert_transaction(acc.id, reverse_transaction) + 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 0f4c240..aa2a5a5 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 @@ -180,4 +204,37 @@ class Module(ModuleType): 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() + sys.modules[__name__] = Module(__name__) diff --git a/server/foxbank_server/models.py b/server/foxbank_server/models.py index 8d89a82..872844e 100644 --- a/server/foxbank_server/models.py +++ b/server/foxbank_server/models.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from marshmallow import Schema, fields from datetime import datetime @@ -51,6 +51,7 @@ class Account: currency: str account_type: str custom_name: str + balance: int = field(default=0) class AccountSchema(Schema): id = fields.Int(required=False) @@ -58,6 +59,7 @@ class Account: 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': @@ -75,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 @@ -97,9 +100,10 @@ class Transaction: class TransactionSchema(Schema): id = fields.Int(required=False) date_time = fields.DateTime(data_key='datetime') - other_party = fields.Str(data_key='otherParty') + 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.Str() + 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 = '') -> 'Account': @@ -126,4 +130,14 @@ class Transaction: @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) diff --git a/server/foxbank_server/returns.py b/server/foxbank_server/returns.py index 69bf550..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 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;