Allow users to transfer founds to another account

This commit is contained in:
s3lph 2022-07-16 19:15:25 +02:00
parent 68a33d1819
commit 1b35f4ea7d
11 changed files with 343 additions and 39 deletions

View file

@ -1,5 +1,18 @@
# Matemat Changelog # Matemat Changelog
<!-- BEGIN RELEASE v0.2.10 -->
## Version 0.2.10
Feature release
### Changes
<!-- BEGIN CHANGES 0.2.10 -->
- Feature: Let users transfer funds to another account
<!-- END CHANGES 0.2.10 -->
<!-- END RELEASE v0.2.10 -->
<!-- BEGIN RELEASE v0.2.9 --> <!-- BEGIN RELEASE v0.2.9 -->
## Version 0.2.9 ## Version 0.2.9

View file

@ -1,2 +1,2 @@
__version__ = '0.2.9' __version__ = '0.2.10'

View file

@ -562,6 +562,88 @@ class MatematDatabase(object):
# Reflect the change in the user object # Reflect the change in the user object
user.balance = old_balance + amount user.balance = old_balance + amount
def transfer(self, source: User, dest: User, amount: int) -> None:
"""
Transfer funds from one account to another.
:param source: The user account to remove funds from.
:param dest: The user account to add funds to.
:param amount: The amount to transfer between accounts.
:raises DatabaseConsistencyError: If the user represented by the object does not exist.
"""
if amount < 0:
raise ValueError('Cannot transfer a negative value')
with self.db.transaction() as c:
# First, remove amount from the source user's account
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
[source.id])
row = c.fetchone()
if row is None:
raise DatabaseConsistencyError(f'No such user: {source.id}')
source_old_balance: int = row[0]
c.execute('''
INSERT INTO transactions (user_id, value, old_balance)
VALUES (:user_id, :value, :old_balance)
''', {
'user_id': source.id,
'value': -amount,
'old_balance': source_old_balance
})
c.execute('''
INSERT INTO modifications (ta_id, agent, reason)
VALUES (last_insert_rowid(), :user, :reason)
''', {
'user': source.name,
'reason': f'Transfer to {dest.name}'
})
c.execute('''
UPDATE users
SET balance = balance - :amount
WHERE user_id = :user_id
''', {
'user_id': source.id,
'amount': amount
})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
raise DatabaseConsistencyError(f'transfer should affect 1 users row, but affected {affected}')
# Then, add the amount to the destination user's account
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
[dest.id])
row = c.fetchone()
if row is None:
raise DatabaseConsistencyError(f'No such user: {dest.id}')
dest_old_balance: int = row[0]
c.execute('''
INSERT INTO transactions (user_id, value, old_balance)
VALUES (:user_id, :value, :old_balance)
''', {
'user_id': dest.id,
'value': amount,
'old_balance': dest_old_balance
})
c.execute('''
INSERT INTO modifications (ta_id, agent, reason)
VALUES (last_insert_rowid(), :user, :reason)
''', {
'user': source.name,
'reason': f'Transfer from {source.name}'
})
c.execute('''
UPDATE users
SET balance = balance + :amount
WHERE user_id = :user_id
''', {
'user_id': dest.id,
'amount': amount
})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
raise DatabaseConsistencyError(f'deposit should affect 1 users row, but affected {affected}')
# Reflect the change in the user object
source.balance = source_old_balance - amount
dest.balance = dest_old_balance + amount
def check_receipt_due(self, user: User) -> bool: def check_receipt_due(self, user: User) -> bool:
if not isinstance(user.receipt_pref, ReceiptPreference): if not isinstance(user.receipt_pref, ReceiptPreference):
raise TypeError() raise TypeError()

View file

@ -339,6 +339,36 @@ class DatabaseTest(unittest.TestCase):
# Should fail, user id -1 does not exist # Should fail, user id -1 does not exist
db.deposit(user, 42) db.deposit(user, 42)
def test_transfer(self) -> None:
with self.db as db:
with db.transaction() as c:
user = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com', True, True)
user2 = db.create_user('testuser2', 'supersecurepassword', 'testuser@example.com', True, True)
user3 = db.create_user('testuser3', 'supersecurepassword', 'testuser@example.com', True, True)
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user.id])
self.assertEqual(0, c.fetchone()[0])
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user2.id])
self.assertEqual(0, c.fetchone()[0])
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user3.id])
self.assertEqual(0, c.fetchone()[0])
db.transfer(user, user2, 1337)
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user.id])
self.assertEqual(-1337, c.fetchone()[0])
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user2.id])
self.assertEqual(1337, c.fetchone()[0])
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user3.id])
self.assertEqual(0, c.fetchone()[0])
with self.assertRaises(ValueError):
# Should fail, negative amount
db.transfer(user, user2, -42)
user.id = -1
with self.assertRaises(DatabaseConsistencyError):
# Should fail, user id -1 does not exist
db.transfer(user, user2, 42)
with self.assertRaises(DatabaseConsistencyError):
# Should fail, user id -1 does not exist
db.transfer(user2, user, 42)
def test_consumption(self) -> None: def test_consumption(self) -> None:
with self.db as db: with self.db as db:
# Set up test case # Set up test case

View file

@ -10,6 +10,7 @@ from .logout import logout
from .touchkey import touchkey_page from .touchkey import touchkey_page
from .buy import buy from .buy import buy
from .deposit import deposit from .deposit import deposit
from .transfer import transfer
from .admin import admin from .admin import admin
from .metrics import metrics from .metrics import metrics
from .moduser import moduser from .moduser import moduser

View file

@ -19,13 +19,14 @@ def main_page():
uid: int = session.get(session_id, 'authenticated_user') uid: int = session.get(session_id, 'authenticated_user')
authlevel: int = session.get(session_id, 'authentication_level') authlevel: int = session.get(session_id, 'authentication_level')
# Fetch the user object from the database (for name display, price calculation and admin check) # Fetch the user object from the database (for name display, price calculation and admin check)
users = db.list_users()
user = db.get_user(uid) user = db.get_user(uid)
# Fetch the list of products to display # Fetch the list of products to display
products = db.list_products() products = db.list_products()
# Prepare a response with a jinja2 template # Prepare a response with a jinja2 template
return template.render('productlist.html', return template.render('productlist.html',
authuser=user, products=products, authlevel=authlevel, stock=get_stock_provider(), authuser=user, users=users, products=products, authlevel=authlevel,
setupname=config['InstanceName']) stock=get_stock_provider(), setupname=config['InstanceName'])
else: else:
# If there are no admin users registered, jump to the admin creation procedure # If there are no admin users registered, jump to the admin creation procedure
if not db.has_admin_users(): if not db.has_admin_users():

View file

@ -0,0 +1,34 @@
from bottle import get, post, redirect, request
from matemat.db import MatematDatabase
from matemat.webserver import session
from matemat.webserver import config as c
@get('/transfer')
@post('/transfer')
def transfer():
"""
The transfer mechanism to tranfer funds between accounts.
"""
config = c.get_app_config()
session_id: str = session.start()
# If no user is logged in, redirect to the main page, as a purchase must always be bound to a user
if not session.has(session_id, 'authenticated_user'):
redirect('/')
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user from the database
uid: int = session.get(session_id, 'authenticated_user')
user = db.get_user(uid)
if 'target' not in request.params or 'n' not in request.params:
redirect('/')
return;
# Fetch the target user from the database
tuid = int(str(request.params.target))
transfer_user = db.get_user(tuid)
# Read and transfer amount between accounts
amount = int(str(request.params.n))
db.transfer(user, transfer_user, amount)
# Redirect to the main page (where this request should have come from)
redirect('/')

View file

@ -1,5 +1,5 @@
Package: matemat Package: matemat
Version: 0.2.9 Version: 0.2.10
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol> Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
Section: web Section: web
Priority: optional Priority: optional

View file

@ -85,7 +85,7 @@
margin: auto; margin: auto;
width: 320px; width: 320px;
height: 540px; height: 540px;
grid-template-columns: 100px 100px 100px; grid-template-columns: 100px 100px 100px 200px;
grid-template-rows: 100px 100px 100px 100px 100px; grid-template-rows: 100px 100px 100px 100px 100px;
column-gap: 10px; column-gap: 10px;
row-gap: 10px; row-gap: 10px;
@ -196,3 +196,76 @@
grid-row: 5; grid-row: 5;
background: #60f060; background: #60f060;
} }
#numpad-ok.disabled {
background: #606060;
}
#transfer-userlist {
display: grid;
grid-column: 4;
grid-row-start: 1;
grid-row-end: 6;
grid-template-columns: 200px;
grid-template-rows: 25px 1fr 25px;
column-gap: 10px;
row-gap: 10px;
padding: 0;
margin: 0;
}
#transfer-userlist.show {
display: block;
position: relative;
}
#transfer-userlist-list {
margin: 10px 0;
padding: 0;
overflow-y: hidden;
max-height: calc(100% - 150px);
top: 45px;
bottom: 45px;
grid-row: 2;
grid-column: 1;
}
#transfer-userlist-list > li {
display: block;
background: #f0f0f0;
padding: 15px 20px;
margin-bottom: 10px;
list-style-type: none;
font-family: sans-serif;
line-height: 20px;
font-size: 20px;
height: 20px;
}
#transfer-userlist > #scroll-up, #transfer-userlist > #scroll-down {
left: 0;
right: 0;
height: 25px;
text-align: center;
display: block;
background: #f0f0f0;
padding: 20px;
font-family: sans-serif;
line-height: 25px;
font-size: 25px;
}
#transfer-userlist > #scroll-up{
grid-row: 1;
grid-column: 1;
top: 0;
}
#transfer-userlist > #scroll-down {
grid-row: 2;
grid-column: 1;
bottom: 0;
}
#transfer-userlist-list > li.active {
background: #60f060;
}

View file

@ -4,39 +4,99 @@ Number.prototype.pad = function(size) {
return s; return s;
} }
const Mode = {
Deposit: 0,
Buy: 1,
Transfer: 2,
}
let mode = Mode.Deposit;
let product_id = null; let product_id = null;
let target_user = null;
let target_user_li = null;
let deposit = '0'; let deposit = '0';
let button = document.createElement('div'); let button = document.createElement('div');
let button_transfer = document.createElement('div');
let input = document.getElementById('deposit-wrapper'); let input = document.getElementById('deposit-wrapper');
let amount = document.getElementById('deposit-amount'); let amount = document.getElementById('deposit-amount');
let title = document.getElementById('deposit-title'); let title = document.getElementById('deposit-title');
let userlist = document.getElementById('transfer-userlist');
let userlist_list = document.getElementById('transfer-userlist-list');
let ok_button = document.getElementById('numpad-ok');
button.classList.add('thumblist-item'); button.classList.add('thumblist-item');
button.classList.add('fakelink'); button.classList.add('fakelink');
button.innerText = 'Deposit'; button.innerText = 'Deposit';
button.onclick = (ev) => { button.onclick = (ev) => {
mode = Mode.Deposit;
product_id = null; product_id = null;
target_user = null;
deposit = '0'; deposit = '0';
title.innerText = 'Deposit'; title.innerText = 'Deposit';
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad(); amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
input.classList.add('show'); input.classList.add('show');
userlist.classList.remove('show');
ok_button.classList.remove('disabled');
};
button_transfer.classList.add('thumblist-item');
button_transfer.classList.add('fakelink');
button_transfer.innerText = 'Transfer';
button_transfer.onclick = (ev) => {
mode = Mode.Transfer;
product_id = null;
target_user = null;
deposit = '0';
title.innerText = 'Transfer';
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
input.classList.add('show');
userlist.classList.add('show');
ok_button.classList.add('disabled');
}; };
setup_custom_price = (pid, pname) => { setup_custom_price = (pid, pname) => {
mode = Mode.Buy;
product_id = pid; product_id = pid;
target_user = null;
title.innerText = pname; title.innerText = pname;
deposit = '0'; deposit = '0';
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad(); amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
input.classList.add('show'); input.classList.add('show');
userlist.classList.remove('show');
ok_button.classList.remove('disabled');
}; };
set_transfer_user = (li, uid) => {
if (target_user_li != null) {
target_user_li.classList.remove('active');
}
target_user = uid;
target_user_li = li;
ok_button.classList.remove('disabled');
target_user_li.classList.add('active');
}
scrollUserlist = (delta) => {
userlist_list.scrollBy(0, delta);
}
deposit_key = (k) => { deposit_key = (k) => {
if (k == 'ok') { if (k == 'ok') {
if (product_id === null) { switch (mode) {
case Mode.Deposit:
window.location.href = '/deposit?n=' + parseInt(deposit); window.location.href = '/deposit?n=' + parseInt(deposit);
} else { break;
case Mode.Buy:
window.location.href = '/buy?pid=' + product_id + '&price=' + parseInt(deposit); window.location.href = '/buy?pid=' + product_id + '&price=' + parseInt(deposit);
break;
case Mode.Transfer:
if (target_user == null) {
return;
} }
window.location.href = '/transfer?target=' + target_user + '&n=' + parseInt(deposit);
break;
}
mode = Mode.Deposit;
deposit = '0'; deposit = '0';
product_id = null; product_id = null;
target_user = null;
input.classList.remove('show'); input.classList.remove('show');
userlist.classList.remove('show');
} else if (k == 'del') { } else if (k == 'del') {
if (deposit == '0') { if (deposit == '0') {
product_id = null; product_id = null;
@ -60,3 +120,4 @@ deposit_key = (k) => {
let list = document.getElementById('depositlist'); let list = document.getElementById('depositlist');
list.innerHTML = ''; list.innerHTML = '';
list.appendChild(button); list.appendChild(button);
list.appendChild(button_transfer);

View file

@ -33,6 +33,15 @@
{% for i in [('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('del', '✗'), ('0', '0'), ('ok', '✓')] %} {% for i in [('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('del', '✗'), ('0', '0'), ('ok', '✓')] %}
<div class="numpad" id="numpad-{{ i.0 }}" onclick="deposit_key('{{ i.0 }}');">{{ i.1 }}</div> <div class="numpad" id="numpad-{{ i.0 }}" onclick="deposit_key('{{ i.0 }}');">{{ i.1 }}</div>
{% endfor %} {% endfor %}
<div id="transfer-userlist">
<div id="scroll-up" onclick="scrollUserlist(-130);"></div>
<ul id="transfer-userlist-list">
{% for user in ((users if user != authuser) | sort(attribute='name')) %}
<li onclick="set_transfer_user(this, {{ user.id }})">{{ user.name }}</li>
{% endfor %}
</ul>
<div id="scroll-down" onclick="scrollUserlist(+130);"></div>
</div>
</div> </div>
</div> </div>
<script src="/static/js/depositlist.js"></script> <script src="/static/js/depositlist.js"></script>