diff --git a/plugins/POS.py b/plugins/POS.py index 42fd8a8..c0243c5 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -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] diff --git a/plugins/accounts.py b/plugins/accounts.py index 76a52b8..3d426e2 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -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"] @@ -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": @@ -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?" diff --git a/tests/js/kassa-buttons.test.js b/tests/js/kassa-buttons.test.js index c418229..c96196e 100644 --- a/tests/js/kassa-buttons.test.js +++ b/tests/js/kassa-buttons.test.js @@ -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", @@ -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", @@ -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", @@ -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 = { diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index 62cd8d1..71fe322 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -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=[ diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index 0be323d..78e40ce 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -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) @@ -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"}'), @@ -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", "[]"), + ] ) diff --git a/www/kassa-buttons.js b/www/kassa-buttons.js index ad25a3c..08b3c64 100644 --- a/www/kassa-buttons.js +++ b/www/kassa-buttons.js @@ -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]; @@ -79,6 +83,7 @@ buttons.push({ text: user, display: user, + class: "account account-" + cssSafeClassName(user), rightclass: extraclass, right: amount.toFixed(2), fill: true, @@ -144,6 +149,7 @@ commandsToButtons: commandsToButtons, compareDisplay: compareDisplay, compareTextReverse: compareTextReverse, + cssSafeClassName: cssSafeClassName, productGroupToButtons: productGroupToButtons, productsToButtons: productsToButtons, rebuildProductIndexes: rebuildProductIndexes, diff --git a/www/mystyle.css b/www/mystyle.css index 747821b..ce76258 100644 --- a/www/mystyle.css +++ b/www/mystyle.css @@ -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; } diff --git a/www/spaceconsole/mystyle.css b/www/spaceconsole/mystyle.css index 747821b..ce76258 100644 --- a/www/spaceconsole/mystyle.css +++ b/www/spaceconsole/mystyle.css @@ -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; } diff --git a/www/spaceconsole/stom.css b/www/spaceconsole/stom.css index cffde6c..d1617a7 100644 --- a/www/spaceconsole/stom.css +++ b/www/spaceconsole/stom.css @@ -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; } diff --git a/www/stom.css b/www/stom.css index cffde6c..d1617a7 100644 --- a/www/stom.css +++ b/www/stom.css @@ -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; }