1
0
Fork 0
forked from s3lph/matemat

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
<!-- 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 -->
## 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
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:
if not isinstance(user.receipt_pref, ReceiptPreference):
raise TypeError()

View file

@ -339,6 +339,36 @@ class DatabaseTest(unittest.TestCase):
# Should fail, user id -1 does not exist
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:
with self.db as db:
# Set up test case

View file

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

View file

@ -19,13 +19,14 @@ def main_page():
uid: int = session.get(session_id, 'authenticated_user')
authlevel: int = session.get(session_id, 'authentication_level')
# Fetch the user object from the database (for name display, price calculation and admin check)
users = db.list_users()
user = db.get_user(uid)
# Fetch the list of products to display
products = db.list_products()
# Prepare a response with a jinja2 template
return template.render('productlist.html',
authuser=user, products=products, authlevel=authlevel, stock=get_stock_provider(),
setupname=config['InstanceName'])
authuser=user, users=users, products=products, authlevel=authlevel,
stock=get_stock_provider(), setupname=config['InstanceName'])
else:
# If there are no admin users registered, jump to the admin creation procedure
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
Version: 0.2.9
Version: 0.2.10
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
Section: web
Priority: optional

View file

@ -85,7 +85,7 @@
margin: auto;
width: 320px;
height: 540px;
grid-template-columns: 100px 100px 100px;
grid-template-columns: 100px 100px 100px 200px;
grid-template-rows: 100px 100px 100px 100px 100px;
column-gap: 10px;
row-gap: 10px;
@ -196,3 +196,76 @@
grid-row: 5;
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,59 +4,120 @@ Number.prototype.pad = function(size) {
return s;
}
const Mode = {
Deposit: 0,
Buy: 1,
Transfer: 2,
}
let mode = Mode.Deposit;
let product_id = null;
let target_user = null;
let target_user_li = null;
let deposit = '0';
let button = document.createElement('div');
let button_transfer = document.createElement('div');
let input = document.getElementById('deposit-wrapper');
let amount = document.getElementById('deposit-amount');
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('fakelink');
button.innerText = 'Deposit';
button.onclick = (ev) => {
mode = Mode.Deposit;
product_id = null;
target_user = null;
deposit = '0';
title.innerText = 'Deposit';
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
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) => {
mode = Mode.Buy;
product_id = pid;
target_user = null;
title.innerText = pname;
deposit = '0';
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) => {
if (k == 'ok') {
if (product_id === null) {
window.location.href = '/deposit?n=' + parseInt(deposit);
} else {
window.location.href = '/buy?pid=' + product_id + '&price=' + parseInt(deposit);
}
deposit = '0';
product_id = null;
input.classList.remove('show');
if (k == 'ok') {
switch (mode) {
case Mode.Deposit:
window.location.href = '/deposit?n=' + parseInt(deposit);
break;
case Mode.Buy:
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';
product_id = null;
target_user = null;
input.classList.remove('show');
userlist.classList.remove('show');
} else if (k == 'del') {
if (deposit == '0') {
product_id = null;
input.classList.remove('show');
}
deposit = deposit.substr(0, deposit.length - 1);
if (deposit.length == 0) {
deposit = '0';
}
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
} else {
if (deposit == '0') {
deposit = k;
if (deposit == '0') {
product_id = null;
input.classList.remove('show');
}
deposit = deposit.substr(0, deposit.length - 1);
if (deposit.length == 0) {
deposit = '0';
}
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
} else {
deposit += k;
}
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
if (deposit == '0') {
deposit = k;
} else {
deposit += k;
}
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
}
};
let list = document.getElementById('depositlist');
list.innerHTML = '';
list.appendChild(button);
list.appendChild(button_transfer);

View file

@ -25,15 +25,24 @@
</div>
</div>
<div id="deposit-wrapper">
<div id="deposit-input">
<div id="deposit-output">
<span id="deposit-title"></span>
<span id="deposit-amount">0.00</span>
</div>
{% 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>
{% endfor %}
</div>
<div id="deposit-input">
<div id="deposit-output">
<span id="deposit-title"></span>
<span id="deposit-amount">0.00</span>
</div>
{% 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>
{% 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>
<script src="/static/js/depositlist.js"></script>
<br/>