diff --git a/plugins/POS.py b/plugins/POS.py index c0243c5..c9920a4 100644 --- a/plugins/POS.py +++ b/plugins/POS.py @@ -199,28 +199,22 @@ def printdeclaratie(self, args): self.open() self.slowwrite(BON) - def makepartybon(self, started_amount, current_amount, settled_amount, started_at): + def makepartybon(self, settlement): BON = PRINTER + LARGE + CENTER + LOGO BON += NORMAL + b"Party mode afrekening\n\n" BON += RIGHT + b"%s - Arnhem\n" % time.strftime("%Y-%m-%d %H:%M:%S").encode() BON += LEFT + b"\n" - BON += b"Gestart: %s\n" % started_at.encode() - BON += b"Begonnen met: %8.2f\n" % started_amount - BON += b"Over: %8.2f\n" % current_amount - BON += b"Afgerekend: %8.2f\n" % settled_amount + BON += b"Gestart: %s\n" % settlement["started_at"].encode() + BON += b"Begonnen met: %8.2f\n" % settlement["started_amount"] + BON += b"Bijgestort: %8.2f\n" % settlement["credited_amount"] + BON += b"Over: %8.2f\n" % settlement["current_amount"] + BON += b"Afgerekend: %8.2f\n" % settlement["settled_amount"] BON += LEFT + FEED + CUT return BON - def printparty(self, started_amount, current_amount, settled_amount, started_at): + def printparty(self, settlement): self.open() - self.slowwrite( - self.makepartybon( - started_amount, - current_amount, - settled_amount, - started_at, - ) - ) + self.slowwrite(self.makepartybon(settlement)) def hook_post_checkout(self, user): self.loadbons() diff --git a/plugins/git.py b/plugins/git.py index 0e3783a..23aaab8 100644 --- a/plugins/git.py +++ b/plugins/git.py @@ -1,6 +1,17 @@ # -*- coding: utf-8 -*- +import fcntl +import logging +import os import subprocess import threading +import time + +logger = logging.getLogger(__name__) + +DATA_REPO = "data" +KASSA_GIT_LOCK = os.path.join(DATA_REPO, ".kassa-git.lock") +GIT_INDEX_LOCK = os.path.join(DATA_REPO, ".git", "index.lock") +STALE_INDEX_LOCK_SECONDS = 60 class git: @@ -10,13 +21,78 @@ def __init__(self, SID, master): self.master = master self.SID = SID + def _run_git(self, args): + return subprocess.run( + ["git", *args], + cwd=DATA_REPO, + check=False, + capture_output=True, + text=True, + ) + + def _remove_stale_index_lock(self): + try: + age = time.time() - os.path.getmtime(GIT_INDEX_LOCK) + except FileNotFoundError: + return False + + if age < STALE_INDEX_LOCK_SECONDS: + logger.warning( + "data_git_index_lock_present sid=%s age_seconds=%.1f", + self.SID, + age, + ) + return False + + try: + os.unlink(GIT_INDEX_LOCK) + except FileNotFoundError: + return False + except OSError: + logger.exception("data_git_index_lock_remove_failed sid=%s", self.SID) + return False + + logger.warning( + "data_git_index_lock_removed sid=%s age_seconds=%.1f", + self.SID, + age, + ) + return True + + def _commit(self): + add_result = self._run_git(["add", "-A", "."]) + commit_result = self._run_git(["commit", "-m", str(self.master.transID), "."]) + combined_output = (add_result.stderr or "") + (commit_result.stderr or "") + + if "index.lock" in combined_output and self._remove_stale_index_lock(): + add_result = self._run_git(["add", "-A", "."]) + commit_result = self._run_git( + ["commit", "-m", str(self.master.transID), "."] + ) + + if add_result.returncode != 0 or commit_result.returncode != 0: + message = "\n".join( + part + for part in ( + add_result.stderr.strip(), + commit_result.stderr.strip(), + commit_result.stdout.strip(), + ) + if part + ) + if "nothing to commit" not in message: + logger.warning( + "data_git_commit_failed sid=%s error=%s", self.SID, message + ) + def background(self): with self.lock: - subprocess.run( - ["git", "commit", "-m", str(self.master.transID), "."], - cwd="data", - check=False, - ) + with open(KASSA_GIT_LOCK, "w", encoding="utf-8") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + self._commit() + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) def hook_post_checkout(self, _text): threading.Thread(target=self.background).start() diff --git a/plugins/party.py b/plugins/party.py index a409ef8..b46e6c2 100644 --- a/plugins/party.py +++ b/plugins/party.py @@ -1,6 +1,7 @@ import json import logging import os +import re import tempfile import time @@ -8,6 +9,8 @@ PARTY_USER = "party" STATE_FILE = "data/revbank.party" +LOG_FILE = "data/revbank.log" +GAIN_RE = re.compile(r"^(\S+) BALANCE\s+\d+\s+(\S+) had .* gained \+([0-9.]+),") def _atomic_write(path, data): @@ -31,6 +34,7 @@ def _atomic_write(path, data): class party: active = False started_amount = 0.0 + credited_amount = 0.0 started_at = "" def __init__(self, SID, master): @@ -38,6 +42,7 @@ def __init__(self, SID, master): self.SID = SID self.active = False self.started_amount = 0.0 + self.credited_amount = 0.0 self.started_at = "" def help(self): @@ -54,6 +59,7 @@ def member_override(self): def loadstate(self): self.active = False self.started_amount = 0.0 + self.credited_amount = 0.0 self.started_at = "" try: with open(STATE_FILE, encoding="utf-8") as f: @@ -61,6 +67,10 @@ def loadstate(self): self.active = bool(data.get("active", False)) self.started_amount = float(data.get("started_amount", 0.0)) self.started_at = str(data.get("started_at", "")) + if "credited_amount" in data: + self.credited_amount = float(data.get("credited_amount", 0.0)) + elif self.active: + self.credited_amount = self._credited_amount_from_log() except FileNotFoundError: return except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc: @@ -72,10 +82,31 @@ def writestate(self): { "active": self.active, "started_amount": self.started_amount, + "credited_amount": self.credited_amount, "started_at": self.started_at, }, ) + def _credited_amount_from_log(self): + credited_amount = 0.0 + try: + with open(LOG_FILE, encoding="utf-8") as f: + for line in f: + match = GAIN_RE.match(line) + if not match: + continue + timestamp, user, value = match.groups() + if user == PARTY_USER and timestamp >= self.started_at: + credited_amount += float(value) + except FileNotFoundError: + return 0.0 + except (OSError, ValueError) as exc: + logger.warning( + "party_credit_log_load_failed sid=%s error=%s", self.SID, exc + ) + return 0.0 + return round(credited_amount, 2) + def _ensure_party_account(self): accounts = self.master.accounts if PARTY_USER not in accounts.accounts: @@ -102,6 +133,7 @@ def _enable(self): return True self.active = True self.started_amount = current_amount + self.credited_amount = 0.0 self.started_at = time.strftime("%Y-%m-%d_%H:%M:%S") self.writestate() self._publish_members() @@ -119,14 +151,18 @@ def _disable(self): return True self.master.accounts.readaccounts() current_amount = self._ensure_party_account() - settled_amount = round(self.started_amount - current_amount, 2) + settled_amount = round( + self.started_amount + self.credited_amount - current_amount, 2 + ) + settlement = { + "started_amount": self.started_amount, + "credited_amount": self.credited_amount, + "current_amount": current_amount, + "settled_amount": settled_amount, + "started_at": self.started_at, + } try: - self.master.POS.printparty( - self.started_amount, - current_amount, - settled_amount, - self.started_at, - ) + self.master.POS.printparty(settlement) except Exception as exc: # pylint: disable=broad-exception-caught logger.exception("party_receipt_print_failed sid=%s", self.SID) self.master.send_message( @@ -142,11 +178,24 @@ def _disable(self): self.master.send_message( True, "message", - "Party mode off; started EUR %.2f, left EUR %.2f, settled EUR %.2f" - % (self.started_amount, current_amount, settled_amount), + "Party mode off; started EUR %.2f, credited EUR %.2f, " + "left EUR %.2f, settled EUR %.2f" + % ( + self.started_amount, + self.credited_amount, + current_amount, + settled_amount, + ), ) return True + def hook_balance(self, args): + usr, had, has, _transID = args + if not self.active or usr != PARTY_USER or has <= had: + return + self.credited_amount = round(self.credited_amount + (has - had), 2) + self.writestate() + def input(self, text): if text == "partymodeon": return self._enable() diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index 71fe322..df376cc 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -199,17 +199,22 @@ def test_printdeclaratie(self): self.POS.slowwrite.assert_called() def test_makepartybon(self): - bon = self.POS.makepartybon( - started_amount=100.0, - current_amount=72.5, - settled_amount=27.5, - started_at="2026-05-28_20:00:00", - ) + settlement = { + "started_amount": 100.0, + "current_amount": 72.5, + "settled_amount": 27.5, + "started_at": "2026-05-28_20:00:00", + "credited_amount": 25.0, + } + + bon = self.POS.makepartybon(settlement) assert b"Party mode afrekening" in bon assert b"Gestart: 2026-05-28_20:00:00" in bon assert b"Begonnen met:" in bon assert b" 100.00" in bon + assert b"Bijgestort:" in bon + assert b" 25.00" in bon assert b"Over:" in bon assert b" 72.50" in bon assert b"Afgerekend:" in bon @@ -219,7 +224,15 @@ def test_printparty(self): with patch.object(self.POS, "open") as mock_open_pos, patch.object( self.POS, "slowwrite" ) as mock_slowwrite: - self.POS.printparty(100.0, 72.5, 27.5, "2026-05-28_20:00:00") + self.POS.printparty( + { + "started_amount": 100.0, + "current_amount": 72.5, + "settled_amount": 27.5, + "started_at": "2026-05-28_20:00:00", + "credited_amount": 25.0, + } + ) mock_open_pos.assert_called_once() mock_slowwrite.assert_called_once() diff --git a/tests/plugins/test_git.py b/tests/plugins/test_git.py index a493293..4d482d9 100644 --- a/tests/plugins/test_git.py +++ b/tests/plugins/test_git.py @@ -1,20 +1,130 @@ -from unittest.mock import Mock, patch +import os +import subprocess +import time +from unittest.mock import Mock, call, patch import plugins.git as git_module +def git_result(returncode=0, stdout="", stderr=""): + return subprocess.CompletedProcess( + args=["git"], + returncode=returncode, + stdout=stdout, + stderr=stderr, + ) + + class TestGit: def setup_method(self): self.master_mock = Mock() + self.master_mock.transID = 481031730 self.git = git_module.git("SID", self.master_mock) - def test_background(self): - with patch("plugins.git.subprocess.run") as mock_run: + def test_background(self, tmp_path): + lock_path = tmp_path / ".kassa-git.lock" + with patch.object(git_module, "KASSA_GIT_LOCK", str(lock_path)), patch.object( + self.git, "_commit" + ) as mock_commit, patch("plugins.git.fcntl.flock") as mock_flock: self.git.background() - mock_run.assert_called_with( - ["git", "commit", "-m", str(self.master_mock.transID), "."], + + mock_commit.assert_called_once_with() + mock_flock.assert_has_calls( + [ + call(mock_flock.call_args_list[0].args[0], git_module.fcntl.LOCK_EX), + call(mock_flock.call_args_list[0].args[0], git_module.fcntl.LOCK_UN), + ] + ) + + def test_commit_adds_and_commits_data_repo(self): + with patch("plugins.git.subprocess.run", return_value=git_result()) as mock_run: + self.git._commit() + + assert mock_run.call_args_list == [ + call( + ["git", "add", "-A", "."], cwd="data", check=False, - ) + capture_output=True, + text=True, + ), + call( + ["git", "commit", "-m", "481031730", "."], + cwd="data", + check=False, + capture_output=True, + text=True, + ), + ] + + def test_commit_removes_stale_index_lock_and_retries(self, tmp_path): + git_dir = tmp_path / ".git" + git_dir.mkdir() + index_lock = git_dir / "index.lock" + index_lock.write_text("", encoding="utf-8") + old_timestamp = time.time() - git_module.STALE_INDEX_LOCK_SECONDS - 1 + os.utime(index_lock, (old_timestamp, old_timestamp)) + index_lock_error = git_result( + returncode=128, + stderr="fatal: Unable to create 'data/.git/index.lock': File exists.", + ) + + with patch.object(git_module, "DATA_REPO", str(tmp_path)), patch.object( + git_module, "GIT_INDEX_LOCK", str(index_lock) + ), patch( + "plugins.git.subprocess.run", + side_effect=[git_result(), index_lock_error, git_result(), git_result()], + ) as mock_run: + self.git._commit() + + assert not index_lock.exists() + assert mock_run.call_count == 4 + + def test_commit_keeps_recent_index_lock(self, tmp_path): + git_dir = tmp_path / ".git" + git_dir.mkdir() + index_lock = git_dir / "index.lock" + index_lock.write_text("", encoding="utf-8") + index_lock_error = git_result( + returncode=128, + stderr="fatal: Unable to create 'data/.git/index.lock': File exists.", + ) + + with patch.object(git_module, "DATA_REPO", str(tmp_path)), patch.object( + git_module, "GIT_INDEX_LOCK", str(index_lock) + ), patch( + "plugins.git.subprocess.run", side_effect=[git_result(), index_lock_error] + ): + self.git._commit() + + assert index_lock.exists() + + def test_remove_stale_index_lock_missing_file(self, tmp_path): + with patch.object(git_module, "GIT_INDEX_LOCK", str(tmp_path / "index.lock")): + assert self.git._remove_stale_index_lock() is False + + def test_remove_stale_index_lock_disappears_before_unlink(self, tmp_path): + index_lock = tmp_path / "index.lock" + index_lock.write_text("", encoding="utf-8") + old_timestamp = time.time() - git_module.STALE_INDEX_LOCK_SECONDS - 1 + os.utime(index_lock, (old_timestamp, old_timestamp)) + + with patch.object(git_module, "GIT_INDEX_LOCK", str(index_lock)), patch( + "plugins.git.os.unlink", side_effect=FileNotFoundError + ): + assert self.git._remove_stale_index_lock() is False + + def test_remove_stale_index_lock_logs_unlink_errors(self, tmp_path, caplog): + index_lock = tmp_path / "index.lock" + index_lock.write_text("", encoding="utf-8") + old_timestamp = time.time() - git_module.STALE_INDEX_LOCK_SECONDS - 1 + os.utime(index_lock, (old_timestamp, old_timestamp)) + + with patch.object(git_module, "GIT_INDEX_LOCK", str(index_lock)), patch( + "plugins.git.os.unlink", side_effect=OSError("busy") + ): + assert self.git._remove_stale_index_lock() is False + + assert "data_git_index_lock_remove_failed sid=SID" in caplog.text def test_background_uses_shared_lock(self): other = git_module.git("SID2", Mock()) diff --git a/tests/plugins/test_party.py b/tests/plugins/test_party.py index 6c9d9bb..9ffcb9e 100644 --- a/tests/plugins/test_party.py +++ b/tests/plugins/test_party.py @@ -36,12 +36,14 @@ def test_loadstate_missing_file_is_inactive(tmp_path, monkeypatch): plugin = party("SID", make_master()) plugin.active = True plugin.started_amount = 10.0 + plugin.credited_amount = 5.0 plugin.started_at = "old" plugin.loadstate() assert not plugin.active assert plugin.started_amount == 0.0 + assert plugin.credited_amount == 0.0 assert plugin.started_at == "" @@ -62,6 +64,7 @@ def test_writestate_and_loadstate_roundtrip(tmp_path, monkeypatch): plugin = party("SID", make_master()) plugin.active = True plugin.started_amount = 42.5 + plugin.credited_amount = 12.5 plugin.started_at = "2026-05-28_20:00:00" plugin.writestate() @@ -70,9 +73,65 @@ def test_writestate_and_loadstate_roundtrip(tmp_path, monkeypatch): loaded.loadstate() assert loaded.active assert loaded.started_amount == 42.5 + assert loaded.credited_amount == 12.5 assert loaded.started_at == "2026-05-28_20:00:00" +def test_loadstate_without_credit_reconstructs_party_deposits_from_log( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "data").mkdir() + (tmp_path / STATE_FILE).write_text( + json.dumps( + { + "active": True, + "started_amount": 0.0, + "started_at": "2026-06-24_15:19:52", + } + ), + encoding="utf-8", + ) + (tmp_path / "data/revbank.log").write_text( + "\n".join( + [ + "2026-06-24_15:18:00 BALANCE 1 party had +0.00, gained +10.00, now has +10.00", + "2026-06-24_15:23:04 BALANCE 2 party had -1.42, gained +300.00, now has +298.58", + "2026-06-24_15:24:00 BALANCE 3 other had +0.00, gained +50.00, now has +50.00", + "2026-06-24_15:25:44 BALANCE 4 party had +298.58, lost -1.50, now has +297.08", + ] + ), + encoding="utf-8", + ) + plugin = party("SID", make_master()) + + plugin.loadstate() + + assert plugin.active + assert plugin.started_amount == 0.0 + assert plugin.credited_amount == 300.0 + assert plugin.started_at == "2026-06-24_15:19:52" + + +def test_credit_log_missing_file_returns_zero(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + plugin = party("SID", make_master()) + plugin.started_at = "2026-06-24_15:19:52" + + assert plugin._credited_amount_from_log() == 0.0 + + +def test_credit_log_read_errors_return_zero(tmp_path, monkeypatch, caplog): + monkeypatch.chdir(tmp_path) + plugin = party("SID", make_master()) + plugin.started_at = "2026-06-24_15:19:52" + + with patch("plugins.party.open", side_effect=OSError("log unavailable")): + assert plugin._credited_amount_from_log() == 0.0 + + assert "party_credit_log_load_failed" in caplog.text + + def test_atomic_write_removes_temp_file_when_write_fails(tmp_path, monkeypatch): output = tmp_path / "data" / "revbank.party" created_temp = tmp_path / "data" / "revbank.party.tmp" @@ -147,6 +206,7 @@ def test_partymodeon_persists_state_and_publishes_party_member( assert data == { "active": True, "started_amount": 100.0, + "credited_amount": 0.0, "started_at": "2026-05-28_20:00:00", } @@ -174,6 +234,7 @@ def test_partymodeon_when_already_active_keeps_started_amount(tmp_path, monkeypa { "active": True, "started_amount": 75.0, + "credited_amount": 20.0, "started_at": "2026-05-28_19:00:00", } ), @@ -188,6 +249,7 @@ def test_partymodeon_when_already_active_keeps_started_amount(tmp_path, monkeypa assert plugin.active assert plugin.started_amount == 75.0 + assert plugin.credited_amount == 20.0 master.send_message.assert_any_call(True, "message", "Party mode is already on") @@ -199,6 +261,7 @@ def test_startup_restores_active_party_mode(tmp_path, monkeypatch): { "active": True, "started_amount": 100.0, + "credited_amount": 0.0, "started_at": "2026-05-28_20:00:00", } ), @@ -224,6 +287,7 @@ def test_partymodeoff_prints_receipt_and_restores_members(tmp_path, monkeypatch) { "active": True, "started_amount": 100.0, + "credited_amount": 25.0, "started_at": "2026-05-28_20:00:00", } ), @@ -237,13 +301,20 @@ def test_partymodeoff_prints_receipt_and_restores_members(tmp_path, monkeypatch) assert plugin.input("partymodeoff") is True master.POS.printparty.assert_called_once_with( - 100.0, 72.5, 27.5, "2026-05-28_20:00:00" + { + "started_amount": 100.0, + "credited_amount": 25.0, + "current_amount": 72.5, + "settled_amount": 52.5, + "started_at": "2026-05-28_20:00:00", + } ) master.accounts.publish_members.assert_called_once() master.send_message.assert_any_call( True, "message", - "Party mode off; started EUR 100.00, left EUR 72.50, settled EUR 27.50", + "Party mode off; started EUR 100.00, credited EUR 25.00, " + "left EUR 72.50, settled EUR 52.50", ) data = json.loads((tmp_path / STATE_FILE).read_text(encoding="utf-8")) assert data["active"] is False @@ -269,6 +340,7 @@ def test_partymodeoff_keeps_active_when_print_fails(tmp_path, monkeypatch): { "active": True, "started_amount": 100.0, + "credited_amount": 0.0, "started_at": "2026-05-28_20:00:00", } ), @@ -292,5 +364,37 @@ def test_partymodeoff_keeps_active_when_print_fails(tmp_path, monkeypatch): ) +def test_hook_balance_tracks_party_deposits_while_active(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + plugin = party("SID", make_master()) + plugin.active = True + plugin.started_amount = 0.0 + plugin.credited_amount = 0.0 + plugin.started_at = "2026-06-24_15:19:52" + + plugin.hook_balance((PARTY_USER, -1.42, 298.58, 123)) + + assert plugin.credited_amount == 300.0 + data = json.loads((tmp_path / STATE_FILE).read_text(encoding="utf-8")) + assert data["credited_amount"] == 300.0 + + +def test_hook_balance_ignores_losses_other_users_and_inactive_mode( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + plugin = party("SID", make_master()) + plugin.active = True + plugin.credited_amount = 10.0 + + plugin.hook_balance((PARTY_USER, 20.0, 18.0, 123)) + plugin.hook_balance(("other", 0.0, 100.0, 124)) + plugin.active = False + plugin.hook_balance((PARTY_USER, 0.0, 50.0, 125)) + + assert plugin.credited_amount == 10.0 + assert not (tmp_path / STATE_FILE).exists() + + def test_input_ignores_other_text(): assert party("SID", make_master()).input("other") is None