mirror of
https://github.com/dancojocaru2000/foxbank.git
synced 2025-06-19 11:02:28 +03:00
Compare commits
9 commits
e8477db7b8
...
1c56bf2a60
Author | SHA1 | Date | |
---|---|---|---|
|
1c56bf2a60 | ||
|
57d9ff8ed0 | ||
649e2c729f | |||
c7c8f3c765 | |||
9a1e1b4fce | |||
e68709092b | |||
|
ab53e05287 | ||
1250b12049 | |||
28ac7a970e |
17 changed files with 234 additions and 59 deletions
13
client/package-lock.json
generated
13
client/package-lock.json
generated
|
@ -23,6 +23,7 @@
|
|||
"rollup-plugin-svelte": "^7.0.0",
|
||||
"rollup-plugin-terser": "^7.0.0",
|
||||
"svelte": "^3.0.0",
|
||||
"svelte-keydown": "^0.4.0",
|
||||
"svelte-preprocess": "^4.9.8"
|
||||
}
|
||||
},
|
||||
|
@ -1947,6 +1948,12 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-keydown": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-keydown/-/svelte-keydown-0.4.0.tgz",
|
||||
"integrity": "sha512-a0S5m+u78FE1jgpuSCZPtgh/KhwNAO9t8kkcBkK1sK9ZiVC+Iu5XOCqIu1F+PEyenRTIxGaA4yr6KAsYFB6kkQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/svelte-preprocess": {
|
||||
"version": "4.9.8",
|
||||
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-4.9.8.tgz",
|
||||
|
@ -3700,6 +3707,12 @@
|
|||
"integrity": "sha512-4DrCEJoBvdR689efHNSxIQn2pnFwB7E7j2yLEJtHE/P8hxwZWIphCtJ8are7bjl/iVMlcEf5uh5pJ68IwR09vQ==",
|
||||
"dev": true
|
||||
},
|
||||
"svelte-keydown": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-keydown/-/svelte-keydown-0.4.0.tgz",
|
||||
"integrity": "sha512-a0S5m+u78FE1jgpuSCZPtgh/KhwNAO9t8kkcBkK1sK9ZiVC+Iu5XOCqIu1F+PEyenRTIxGaA4yr6KAsYFB6kkQ==",
|
||||
"dev": true
|
||||
},
|
||||
"svelte-preprocess": {
|
||||
"version": "4.9.8",
|
||||
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-4.9.8.tgz",
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
"rollup-plugin-svelte": "^7.0.0",
|
||||
"rollup-plugin-terser": "^7.0.0",
|
||||
"svelte": "^3.0.0",
|
||||
"svelte-keydown": "^0.4.0",
|
||||
"svelte-preprocess": "^4.9.8"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
export let balance="5425";
|
||||
export let iban="RONFOX62188921";
|
||||
export let isExpanded=false;
|
||||
let transactions=[];
|
||||
export let transactions=[];
|
||||
export let accountId;
|
||||
|
||||
let copied = false;
|
||||
|
@ -55,14 +55,6 @@
|
|||
});
|
||||
}
|
||||
|
||||
onMount( () => {
|
||||
gettransactions($token, accountId).then( result => {
|
||||
if(result.status == "success") {
|
||||
transactions = result.transactions;
|
||||
transactions.sort((e1, e2) => new Date(e2.datetime) - new Date(e1.datetime));
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
|
@ -82,7 +74,7 @@
|
|||
|
||||
<div>
|
||||
<DetailField class="p-1 flex-shrink">
|
||||
<h2 class='font-sans mt-3 mb-2 pl-2 text-4xl text-gray-50'>Balance: <span style="color: #264D59">{balance}</span>{currency}</h2>
|
||||
<h2 class='font-sans mt-3 mb-2 pl-2 text-4xl text-gray-50'>Balance: <span style="color: #264D59">{amountToString(balance)}</span>{currency}</h2>
|
||||
</DetailField>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -42,21 +42,39 @@
|
|||
})
|
||||
|
||||
setContext("user", user);
|
||||
const refreshAccounts = writable(null);
|
||||
setContext("refreshAccounts", refreshAccounts);
|
||||
|
||||
const accounts = readable(null, set => {
|
||||
const unsubscribe = userToken.subscribe(token => {
|
||||
if(token == null) {
|
||||
function getAccounts(token){
|
||||
if(token==null){
|
||||
set(null);
|
||||
}else{
|
||||
getaccountlist(token)
|
||||
.then(result =>{
|
||||
.then(result => {
|
||||
set(result);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let token = null;
|
||||
|
||||
refreshAccounts.set( () => {
|
||||
getAccounts(token);
|
||||
});
|
||||
|
||||
const unsubscribe = userToken.subscribe(newToken => {
|
||||
token = newToken;
|
||||
getAccounts(token);
|
||||
})
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
getAccounts(token);
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -39,35 +39,44 @@
|
|||
<div class="w-full max-w-md self-start border-solid border mb-3"></div>
|
||||
|
||||
<div class="flex flex-col flex-grow pl-8 pr-10 relative scroller overflow-auto overflow-x-hidden max-h-full min-h-0">
|
||||
{#each notifications as notification,i (i)}
|
||||
<div on:click={() => onNotificationClick(notification.id)} in:slide={{delay:100*i}} out:slide={{delay:50*(notifications.length-i)}}>
|
||||
{#if notifications.length > 0}
|
||||
{#each notifications as notification,i (i)}
|
||||
<div on:click={() => onNotificationClick(notification.id)} in:slide={{delay:100*i}} out:slide={{delay:50*(notifications.length-i)}}>
|
||||
|
||||
<DetailField class="relative my-3 py-1 flex-shrink min-w-transaction max-w-4xl">
|
||||
<div class='font-sans text-gray-50 text-2xl mt-2 mx-4 border-b-1'>
|
||||
{notification.body}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row">
|
||||
<div class='inline font-sans ml-auto mr-4 text-xl text-gray-100 mt-2 mx-6 border-b-1'>
|
||||
<span> at {new Date(notification.datetime).toLocaleString()} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='fixed font-sans text-6xl text-gray-100 left-2 bottom-2'>
|
||||
{#if !notification.read}
|
||||
<svg class="fill-current text-gray-100" viewBox="0 0 16 16" width="16" height="16">
|
||||
<circle cx="8" cy="8" r="8"></circle>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="fill-current text-gray-100" viewBox="0 0 24 24" width="16" height="16">
|
||||
<path d="M20.285 2l-11.285 11.567-5.286-5.011-3.714 3.716 9 8.728 15-15.285z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</DetailField>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<DetailField class="relative my-3 py-1 flex-shrink min-w-transaction max-w-4xl">
|
||||
<div class='font-sans text-gray-50 text-2xl mt-2 mx-4 border-b-1'>
|
||||
{notification.body}
|
||||
No notifications.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row">
|
||||
<div class='inline font-sans ml-auto mr-4 text-xl text-gray-100 mt-2 mx-6 border-b-1'>
|
||||
<span> at {new Date(notification.datetime).toLocaleString()} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='fixed font-sans text-6xl text-gray-100 left-2 bottom-2'>
|
||||
{#if !notification.read}
|
||||
<svg class="fill-current text-gray-100" viewBox="0 0 16 16" width="16" height="16">
|
||||
<circle cx="8" cy="8" r="8"></circle>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="fill-current text-gray-100" viewBox="0 0 24 24" width="16" height="16">
|
||||
<path d="M20.285 2l-11.285 11.567-5.286-5.011-3.714 3.716 9 8.728 15-15.285z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</DetailField>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="m-2"></div>
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
import { getContext } from "svelte";
|
||||
|
||||
const token = getContext("token");
|
||||
const refreshAccounts = getContext("refreshAccounts");
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
@ -32,6 +33,9 @@
|
|||
const result = await createaccount($token, name, currency, type);
|
||||
if(result.status == "success") {
|
||||
dispatch("createPopup",{type:"create_acc_success"});
|
||||
if($refreshAccounts){
|
||||
$refreshAccounts();
|
||||
}
|
||||
}else{
|
||||
dispatch("createPopup",{type:"create_acc_failed", reason:"Failed to create account. Error:"+result.status});
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</script>
|
||||
|
||||
<main>
|
||||
<input type={isPassword ? "password" : "text"} placeholder={placeholder} value={value} on:input={handleInput} class="placeholder-gray-300 p-3 text-gray-50 w-full text-3xl">
|
||||
<input on:keydown type={isPassword ? "password" : "text"} placeholder={placeholder} value={value} on:input={handleInput} class="placeholder-gray-300 p-3 text-gray-50 w-full text-3xl">
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -24,6 +24,19 @@
|
|||
|
||||
}
|
||||
|
||||
async function enter(event) {
|
||||
if (event.key === 'Enter') {
|
||||
const result = await login(username, code);
|
||||
if(result.status == "success") {
|
||||
dispatch("loginSuccess",{
|
||||
token: result.token,
|
||||
});
|
||||
}else{
|
||||
alert(result.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<main class="h-full">
|
||||
|
@ -35,11 +48,11 @@
|
|||
</div>
|
||||
|
||||
<div class="m-3 flex-shrink">
|
||||
<InputField placeholder="Code" isPassword={true} bind:value={code}></InputField>
|
||||
<InputField on:keydown={enter} placeholder="Code" isPassword={true} bind:value={code}></InputField>
|
||||
</div>
|
||||
|
||||
<div class="m-3 flex-shrink">
|
||||
<OrangeButton on:click={checkLogin}>Login</OrangeButton>
|
||||
<OrangeButton on:click={checkLogin}>Login</OrangeButton>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow">
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
import GreenButton from './GreenButton.svelte';
|
||||
import { fade, fly, slide } from 'svelte/transition';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { logout, whoami, getaccountlist, getnotificationlist } from './api';
|
||||
import { amountToString } from './utils';
|
||||
import { logout, whoami, getaccountlist, getnotificationlist, getForex } from './api';
|
||||
import { amountToString } from './utils';
|
||||
|
||||
const token = getContext("token");
|
||||
const user = getContext("user");
|
||||
|
@ -20,7 +20,7 @@ import { amountToString } from './utils';
|
|||
$: username = $user.user.username;
|
||||
$: email = $user.user.email;
|
||||
$: notifications = $notificationsStore ? $notificationsStore.notifications : [];
|
||||
let totalbalance = "2455.22";
|
||||
let totalbalance = "0.00";
|
||||
let maincurrency = "RON";
|
||||
let expandedAccount = null;
|
||||
let showAllAccounts = true;
|
||||
|
@ -30,12 +30,21 @@ import { amountToString } from './utils';
|
|||
return {
|
||||
name: account.customName ? account.customName : `${account.accountType} Account`,
|
||||
currency: account.currency,
|
||||
balance: amountToString(account.balance),
|
||||
balance: account.balance,
|
||||
iban: account.iban.replace(/(.{4})/g, "$1 "),
|
||||
id: account.id,
|
||||
transactions: account.transactions,
|
||||
}
|
||||
}) : [];
|
||||
|
||||
$: {
|
||||
Promise.all(accounts.map(account => {
|
||||
return getForex(account.currency, maincurrency, account.balance);
|
||||
})).then( balances => {
|
||||
const sum = balances.reduce((acc, current) => acc+current, 0);
|
||||
totalbalance = amountToString(Math.round(sum));
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchLogout(){
|
||||
if (confirm("Log out?")) {
|
||||
|
@ -92,7 +101,7 @@ import { amountToString } from './utils';
|
|||
{/if}
|
||||
|
||||
<div class="self-center m-0">
|
||||
{#if showAllAccounts}
|
||||
{#if showAllAccounts && (accounts.length < 3) }
|
||||
<div in:slide={{delay:500*accounts.length, duration:250*accounts.length}}>
|
||||
<GreenButton on:click={createAccount}><Icon icon="akar-icons:plus" color="rgba(249, 250, 251, 1)" width="26" height="26" /></GreenButton>
|
||||
</div>
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
let amount=0.00;
|
||||
let description="";
|
||||
const token = getContext("token");
|
||||
const refreshAccounts = getContext("refreshAccounts");
|
||||
|
||||
|
||||
async function create(){
|
||||
|
@ -36,6 +37,9 @@
|
|||
await createtransaction($token, receiveriban, Math.round(amount*100), account.id, description).then( result => {
|
||||
if(result.status == "success") {
|
||||
dispatch("createPopup",{type:"send_money_success"});
|
||||
if($refreshAccounts){
|
||||
$refreshAccounts();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -67,7 +67,18 @@ export async function getaccountlist(token) {
|
|||
},
|
||||
});
|
||||
|
||||
return (await result.json());
|
||||
const data = await result.json();
|
||||
|
||||
if(data.status == "success"){
|
||||
for(let i=0; i < data.accounts.length; i++){
|
||||
const transactionsreq = await gettransactions(token, data.accounts[i].id);
|
||||
if (transactionsreq.status == "success") {
|
||||
data.accounts[i].transactions = transactionsreq.transactions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
|
@ -146,7 +157,12 @@ export async function getnotificationlist(token) {
|
|||
},
|
||||
});
|
||||
|
||||
return (await result.json());
|
||||
const data = await result.json();
|
||||
if(data.status == "success") {
|
||||
data.notifications.sort((e1, e2) => new Date(e2.datetime) - new Date(e1.datetime));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
|
@ -233,7 +249,12 @@ export async function gettransactions(token, id) {
|
|||
},
|
||||
});
|
||||
|
||||
return (await result.json());
|
||||
const data = await result.json();
|
||||
if(data.status == "success") {
|
||||
data.transactions.sort((e1, e2) => new Date(e2.datetime) - new Date(e1.datetime));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
|
@ -241,3 +262,26 @@ export async function gettransactions(token, id) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function getForex(from, to, amount) {
|
||||
try {
|
||||
const result = await fetch(new URL("/forex/"+from+"/"+to, baseURL), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await result.json();
|
||||
if(data.status == "success") {
|
||||
const exchanged = amount * data.rate;
|
||||
return exchanged;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ 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
|
||||
from .forex import bp as forex_bp
|
||||
|
||||
class ApiWithErr(Api):
|
||||
def handle_http_exception(self, error):
|
||||
|
@ -31,3 +32,4 @@ def init_apis(app: Flask):
|
|||
api.register_blueprint(acc_bp, url_prefix='/accounts')
|
||||
api.register_blueprint(transactions_bp, url_prefix='/transactions')
|
||||
api.register_blueprint(notifications_bp, url_prefix='/notifications')
|
||||
api.register_blueprint(forex_bp, url_prefix='/forex')
|
||||
|
|
26
server/foxbank_server/apis/forex.py
Normal file
26
server/foxbank_server/apis/forex.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from flask.views import MethodView
|
||||
from flask_smorest import Blueprint
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
from ..db_utils import get_forex_rate
|
||||
from .. import returns
|
||||
|
||||
bp = Blueprint('forex', __name__, description='Foreign Exchange information')
|
||||
|
||||
class GetExchangeResult(returns.SuccessSchema):
|
||||
rate = fields.Float(optional=True)
|
||||
|
||||
@bp.get('/<from_currency>/<to_currency>')
|
||||
@bp.response(422, returns.ErrorSchema, description='Invalid currency')
|
||||
@bp.response(200, GetExchangeResult)
|
||||
def get_exchange(from_currency: str, to_currency: str):
|
||||
"""Get exchange rate between two currencies"""
|
||||
if from_currency == to_currency:
|
||||
rate = 1
|
||||
else:
|
||||
rate = get_forex_rate(from_currency, to_currency)
|
||||
|
||||
if rate is None:
|
||||
return returns.abort(returns.invalid_argument('currency'))
|
||||
|
||||
return returns.success(rate=rate)
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from flask.views import MethodView
|
||||
from flask_smorest import Blueprint
|
||||
from marshmallow import Schema, fields
|
||||
|
@ -41,7 +41,7 @@ class NotificationsList(MethodView):
|
|||
|
||||
The usefulness of this endpoint is questionable besides debugging since it's a notification to self
|
||||
"""
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc).astimezone()
|
||||
notification = Notification.new_notification(body, now, read)
|
||||
insert_notification(decorators.user_id, notification)
|
||||
return returns.success(notification=notification)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from flask.views import MethodView
|
||||
from flask_smorest import Blueprint
|
||||
from marshmallow import Schema, fields
|
||||
|
@ -6,7 +6,7 @@ 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 ..db_utils import get_transactions, get_account, get_accounts, insert_transaction, whose_account, insert_notification, get_forex_rate
|
||||
from ..models import Account, Notification, Transaction
|
||||
from ..utils.iban import check_iban
|
||||
from .. import decorators, returns
|
||||
|
@ -75,22 +75,25 @@ class TransactionsList(MethodView):
|
|||
if not check_iban(destination_iban):
|
||||
return returns.abort(returns.INVALID_IBAN)
|
||||
|
||||
date = datetime.now()
|
||||
date = datetime.now(timezone.utc).astimezone()
|
||||
|
||||
# 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:
|
||||
rate = get_forex_rate(account.currency, acc.currency)
|
||||
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,
|
||||
'currency': acc.currency,
|
||||
'amount': int(-amount * rate),
|
||||
'description': description,
|
||||
'originalAmount': -amount,
|
||||
'originalCurrency': account.currency,
|
||||
},
|
||||
)
|
||||
insert_transaction(acc.id, reverse_transaction)
|
||||
|
@ -100,7 +103,7 @@ class TransactionsList(MethodView):
|
|||
date_time=date,
|
||||
read=False,
|
||||
)
|
||||
insert_notification(acc.id, notification)
|
||||
insert_notification(whose_account(acc.id), notification)
|
||||
break
|
||||
else:
|
||||
return returns.abort(returns.NOT_FOUND)
|
||||
|
|
|
@ -306,5 +306,35 @@ class Module(ModuleType):
|
|||
)
|
||||
self.db.commit()
|
||||
|
||||
@get_db
|
||||
def get_forex_rate(self, from_currency: str, to_currency: str) -> float | None:
|
||||
if from_currency == to_currency:
|
||||
return 1.0
|
||||
|
||||
cur = self.db.cursor()
|
||||
|
||||
if from_currency == 'RON' or to_currency == 'RON':
|
||||
currency_pairs = [(from_currency, to_currency)]
|
||||
else:
|
||||
currency_pairs = [(from_currency, 'RON'), ('RON', to_currency)]
|
||||
|
||||
amount = 1.0
|
||||
for currency_pair in currency_pairs:
|
||||
to_select = 'to_ron'
|
||||
if currency_pair[0] == 'RON':
|
||||
to_select = 'from_ron'
|
||||
cur.execute(
|
||||
f'select {to_select} from exchange where currency = ?',
|
||||
(currency_pair[1] if currency_pair[0] == 'RON' else currency_pair[0],),
|
||||
)
|
||||
rate = cur.fetchone()
|
||||
if rate is None:
|
||||
amount = None
|
||||
break
|
||||
rate = rate[0]
|
||||
amount *= rate
|
||||
|
||||
return amount
|
||||
|
||||
|
||||
sys.modules[__name__] = Module(__name__)
|
||||
|
|
|
@ -60,6 +60,13 @@ create table users_notifications (
|
|||
foreign key (notification_id) references notifications (id)
|
||||
);
|
||||
|
||||
create table exchange (
|
||||
id integer primary key autoincrement,
|
||||
currency text not null,
|
||||
to_ron real not null,
|
||||
from_ron real not null
|
||||
);
|
||||
|
||||
create view V_account_balance as
|
||||
select
|
||||
accounts_transactions.account_id as "account_id",
|
||||
|
|
Loading…
Add table
Reference in a new issue