Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/POS.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def projected_balance(self, user):
checkout_balances = getattr(self.master.accounts, "checkout_balances", {})
if not isinstance(checkout_balances, dict):
checkout_balances = {}
account = self.master.accounts.accounts[user]
account = self.master.accounts.accounts.get(user, {"amount": 0})
balance_before_checkout = checkout_balances.get(user, account["amount"])
return balance_before_checkout + self.master.receipt.totals[user]

Expand Down
49 changes: 35 additions & 14 deletions plugins/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ def updateaccount(self, usr, value):
logger.debug("update_account sid=%s user=%s value=%s", self.SID, usr, value)
if usr == "cash":
return
if usr not in self.accounts:
logger.warning("missing_account_created_during_checkout user=%s", usr)
self.accounts[usr] = {
"amount": 0,
"lastupdate": time.strftime("%Y-%m-%d_%H:%M:%S"),
}
had = self.accounts[usr]["amount"]
self.accounts[usr]["amount"] += value
has = self.accounts[usr]["amount"]
Expand Down Expand Up @@ -179,7 +185,7 @@ def createnew(self, text):
"amount": 0,
"lastupdate": time.strftime("%Y-%m-%d_%H:%M:%S"),
}
return self.input(self.newaccount)
return self._use_account(self.newaccount)
if text == "no":
return True
if text == "abort":
Expand Down Expand Up @@ -308,26 +314,41 @@ def askalias(self, text):
return self.messageandbuttons("addalias", "keyboard", "What alias to add?")
return None

# This handles the input
def input(self, text):
if self.master.receipt.is_empty() and (
text in self.accounts or text in self.aliases
):
if text in self.aliases:
text = self.aliases[text]
def _resolve_account(self, text):
if text not in self.accounts and text not in self.aliases:
return None
original_text = text
self.readaccounts()
if text in self.aliases:
text = self.aliases[text]
if text in self.accounts:
return text
self.master.send_message(
True, "message", "Account " + original_text + " no longer exists"
)
self.get_last_updated_accounts()
return False

def _use_account(self, account):
if self.master.receipt.is_empty():
self.master.send_message(
False, "infobox/account", json.dumps(self.accounts[text])
False, "infobox/account", json.dumps(self.accounts[account])
)
self.master.send_message(
True, "buttons", json.dumps({"special": "infobox"})
)
return True
if text in self.accounts or text in self.aliases:
if text in self.aliases:
text = self.aliases[text]
self.master.callhook("checkout", text)
self.master.callhook("endsession", text)
self.master.callhook("checkout", account)
self.master.callhook("endsession", account)
return True

# This handles the input
def input(self, text):
account = self._resolve_account(text)
if account is False:
return True
if account is not None:
return self._use_account(account)
command_handlers = {
"adduseralias": lambda: self.messageandbuttons(
"askalias", "accounts", "What user do you want to alias?"
Expand Down
27 changes: 27 additions & 0 deletions tests/js/kassa-buttons.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ test("accountsToButtons filters member types and marks negative balances", () =>

assert.deepEqual(plain(helpers.accountsToButtons(accounts, ["alice"], ["bob"], "m")), [
{
class: "account account-alice",
display: "alice",
fill: true,
right: "-20.00",
Expand All @@ -64,6 +65,7 @@ test("accountsToButtons filters member types and marks negative balances", () =>
]);
assert.deepEqual(plain(helpers.accountsToButtons(accounts, ["alice"], ["bob"], "o")), [
{
class: "account account-bob",
display: "bob",
fill: true,
right: "-2.00",
Expand All @@ -73,6 +75,7 @@ test("accountsToButtons filters member types and marks negative balances", () =>
]);
assert.deepEqual(plain(helpers.accountsToButtons(accounts, ["alice"], ["bob"], "x")), [
{
class: "account account-carol",
display: "carol",
fill: true,
right: "3.50",
Expand All @@ -82,6 +85,30 @@ test("accountsToButtons filters member types and marks negative balances", () =>
]);
});

test("account button classes are safe for CSS selectors", () => {
const helpers = loadButtonHelpers();
const accounts = {
"alice@example": {amount: 1},
};

assert.equal(helpers.cssSafeClassName("alice@example"), "alice-example");
assert.deepEqual(plain(helpers.accountsToButtons(
accounts,
["alice@example"],
[],
"m",
)), [
{
class: "account account-alice-example",
display: "alice@example",
fill: true,
right: "1.00",
rightclass: "alice@example",
text: "alice@example",
},
]);
});

test("product button helpers include prices, stock and aliases for app pages", () => {
const helpers = loadButtonHelpers();
const products = {
Expand Down
22 changes: 22 additions & 0 deletions tests/plugins/test_POS.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,28 @@ def test_makebon_uses_checkout_balance_snapshot_for_new_balance(self):
assert b"Nieuw saldo: 15.00" in bon
assert b"Nieuw saldo: 10.00" not in bon

def test_makebon_missing_account_uses_zero_balance(self):
self.POS.master.receipt = Mock(
receipt=[
{
"product": "test",
"beni": "TestAccountMissing",
"count": 1,
"total": 2.5,
"description": "stale account sale",
}
],
totals={"TestAccountMissing": -2.5},
)
self.POS.master.transID = 42
self.POS.master.accounts.accounts = {}
self.POS.master.accounts.checkout_balances = {}

bon = self.POS.makebon("TestAccountMissing")

assert b"stale account sale" in bon
assert b"Nieuw saldo: -2.50" in bon

def test_makebon_cash_does_not_require_account(self):
self.POS.master.receipt = Mock(
receipt=[
Expand Down
84 changes: 75 additions & 9 deletions tests/plugins/test_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ def test_updateaccount_ignores_cash():
master_mock.callhook.assert_not_called()


@patch("time.strftime")
def test_updateaccount_creates_missing_account(mock_strftime):
mock_strftime.return_value = "2026-06-09_21:02:10"
master_mock = Mock()
master_mock.transID = 481031730
acc = accounts("SID", master_mock)

acc.updateaccount("TestAccountMissing", -2.5)

assert acc.accounts["TestAccountMissing"] == {
"amount": -2.5,
"lastupdate": "2026-06-09_21:02:10",
}
master_mock.callhook.assert_called_with(
"balance", ("TestAccountMissing", 0, -2.5, 481031730)
)


def test_readaccounts():
master_mock = Mock()
acc = accounts("SID", master_mock)
Expand Down Expand Up @@ -551,7 +569,8 @@ def test_input_existing_account():
acc.aliases = {}

# Test with existing account
acc.input("user1")
with patch.object(acc, "readaccounts"):
acc.input("user1")
expected_calls = [
call(False, "infobox/account", "{}"),
call(True, "buttons", '{"special": "infobox"}'),
Expand All @@ -565,20 +584,67 @@ def test_input_alias_empty_receipt_and_checkout_paths():
acc.accounts = {"user1": {"amount": 12.0}}
acc.aliases = {"alias1": "user1"}

master_mock.receipt.is_empty.return_value = True
assert acc.input("alias1") is True
with patch.object(acc, "readaccounts"):
master_mock.receipt.is_empty.return_value = True
assert acc.input("alias1") is True
master_mock.send_message.assert_has_calls(
[
call(False, "infobox/account", '{"amount": 12.0}'),
call(True, "buttons", '{"special": "infobox"}'),
]
)

master_mock.reset_mock()
master_mock.receipt.is_empty.return_value = False
assert acc.input("alias1") is True
master_mock.callhook.assert_has_calls(
[call("checkout", "user1"), call("endsession", "user1")]
)


def test_input_stale_account_is_rejected_before_checkout():
master_mock = Mock()
master_mock.receipt.is_empty.return_value = False
acc = accounts("SID", master_mock)
acc.accounts = {"TestAccountMissing": {"amount": 12.0, "lastupdate": "old"}}
acc.aliases = {}

def clear_accounts():
acc.accounts = {}
acc.aliases = {}

with patch.object(acc, "readaccounts", side_effect=clear_accounts):
assert acc.input("TestAccountMissing") is True

master_mock.callhook.assert_not_called()
master_mock.send_message.assert_has_calls(
[
call(False, "infobox/account", '{"amount": 12.0}'),
call(True, "buttons", '{"special": "infobox"}'),
call(True, "message", "Account TestAccountMissing no longer exists"),
call(True, "nonmembers", "[]"),
]
)

master_mock.reset_mock()

def test_input_alias_to_stale_account_is_rejected_before_checkout():
master_mock = Mock()
master_mock.receipt.is_empty.return_value = False
assert acc.input("alias1") is True
master_mock.callhook.assert_has_calls(
[call("checkout", "user1"), call("endsession", "user1")]
acc = accounts("SID", master_mock)
acc.accounts = {"user1": {"amount": 12.0, "lastupdate": "old"}}
acc.aliases = {"alias1": "user1"}

def clear_accounts():
acc.accounts = {}
acc.aliases = {"alias1": "user1"}

with patch.object(acc, "readaccounts", side_effect=clear_accounts):
assert acc.input("alias1") is True

master_mock.callhook.assert_not_called()
master_mock.send_message.assert_has_calls(
[
call(True, "message", "Account alias1 no longer exists"),
call(True, "nonmembers", "[]"),
]
)


Expand Down
6 changes: 6 additions & 0 deletions www/kassa-buttons.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
return 0;
}

function cssSafeClassName(value) {
return String(value).replace(/[^A-Za-z0-9_-]/g, "-");
}

function rebuildProductIndexes(products, groups, productsWithoutBarcode) {
Object.keys(groups).forEach(function(group) {
delete groups[group];
Expand Down Expand Up @@ -79,6 +83,7 @@
buttons.push({
text: user,
display: user,
class: "account account-" + cssSafeClassName(user),
rightclass: extraclass,
right: amount.toFixed(2),
fill: true,
Expand Down Expand Up @@ -144,6 +149,7 @@
commandsToButtons: commandsToButtons,
compareDisplay: compareDisplay,
compareTextReverse: compareTextReverse,
cssSafeClassName: cssSafeClassName,
productGroupToButtons: productGroupToButtons,
productsToButtons: productsToButtons,
rebuildProductIndexes: rebuildProductIndexes,
Expand Down
9 changes: 9 additions & 0 deletions www/mystyle.css
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,15 @@ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
left: 0;
position: absolute;
}
.account {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.account .Buttontext,
.account .extra {
text-shadow: 0 1px 2px #000;
}
.account.fullwidthButton {
width: 9.75vw;
}
Expand Down
9 changes: 9 additions & 0 deletions www/spaceconsole/mystyle.css
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,15 @@ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
left: 0;
position: absolute;
}
.account {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.account .Buttontext,
.account .extra {
text-shadow: 0 1px 2px #000;
}
.account.fullwidthButton {
width: 9.75vw;
}
Expand Down
9 changes: 9 additions & 0 deletions www/spaceconsole/stom.css
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,15 @@ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
left: 0;
position: absolute;
}
.account {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.account .Buttontext,
.account .extra {
text-shadow: 0 1px 2px #000;
}
.account.fullwidthButton {
width: 9.75vw;
}
Expand Down
9 changes: 9 additions & 0 deletions www/stom.css
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,15 @@ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
left: 0;
position: absolute;
}
.account {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.account .Buttontext,
.account .extra {
text-shadow: 0 1px 2px #000;
}
.account.fullwidthButton {
width: 9.75vw;
}
Expand Down
Loading