home · contact · privacy
Validate bookings balances. master
authorChristian Heller <c.heller@plomlompom.de>
Tue, 21 Jan 2025 03:26:45 +0000 (04:26 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Tue, 21 Jan 2025 03:26:45 +0000 (04:26 +0100)
ledger.py

index aa0619b5e8fc4dad75fb74eb2b1865bfc3ba0378..f82cc41e10481d4aaf53d5d38ad13024ae360fda 100755 (executable)
--- a/ledger.py
+++ b/ledger.py
@@ -5,7 +5,7 @@ from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
 from os import environ
 from pathlib import Path
 from sys import exit as sys_exit
 from os import environ
 from pathlib import Path
 from sys import exit as sys_exit
-from typing import Optional
+from typing import Optional, Self
 from plomlib.web import PlomHttpHandler, PlomHttpServer, PlomQueryMap
 
 
 from plomlib.web import PlomHttpHandler, PlomHttpServer, PlomQueryMap
 
 
@@ -15,6 +15,39 @@ SERVER_HOST = '127.0.0.1'
 PATH_TEMPLATES = Path('templates')
 
 
 PATH_TEMPLATES = Path('templates')
 
 
+class Wealth:
+    """Collects amounts mapped to currencies."""
+
+    def __init__(self, moneys: Optional[dict[str, Decimal]] = None) -> None:
+        self.moneys = moneys if moneys else {}
+
+    def _inc_by(self, other: Self, add=True) -> Self:
+        for currency, amount in other.moneys.items():
+            if currency not in self.moneys:
+                self.moneys[currency] = Decimal(0)
+            self.moneys[currency] += amount if add else -amount
+        return self
+
+    def __iadd__(self, other: Self) -> Self:
+        return self._inc_by(other, True)
+
+    def __isub__(self, other: Self) -> Self:
+        return self._inc_by(other, False)
+
+    @property
+    def sink_empty(self) -> bool:
+        """Return if all evens out to zero."""
+        return not bool(self.as_sink.moneys)
+
+    @property
+    def as_sink(self) -> 'Wealth':
+        """Drop zero amounts, invert non-zero ones."""
+        sink = Wealth()
+        for moneys in [Wealth({c: a}) for c, a in self.moneys.items() if a]:
+            sink = moneys
+        return sink
+
+
 class DatLine:
     """Line of .dat file parsed into comments and machine-readable data."""
 
 class DatLine:
     """Line of .dat file parsed into comments and machine-readable data."""
 
@@ -83,23 +116,22 @@ class TransferLine(BookingLine):
 
     def __init__(self, booking_id: int, code: str) -> None:
         super().__init__(booking_id)
 
     def __init__(self, booking_id: int, code: str) -> None:
         super().__init__(booking_id)
-        self.account, self.currency = '', ''
-        self.amount: Optional[Decimal] = None
         if not code[0].isspace():
         if not code[0].isspace():
-            self.error = 'non-intro line not indented'
+            self.error = 'transfer line not indented'
             return
         toks = code.lstrip().split()
         self.account = toks[0]
             return
         toks = code.lstrip().split()
         self.account = toks[0]
-        if len(toks) not in {1, 3}:
-            self.error = 'illegal number of tokens'
-            return
-        if 3 == len(toks):
+        self.amount: Optional[Decimal] = None
+        if 1 == len(toks):
+            self.currency = ''
+        elif 3 == len(toks):
             self.currency = toks[2]
             try:
                 self.amount = Decimal(toks[1])
             except DecimalInvalidOperation:
                 self.error = 'improper amount value'
             self.currency = toks[2]
             try:
                 self.amount = Decimal(toks[1])
             except DecimalInvalidOperation:
                 self.error = 'improper amount value'
-                return
+        else:
+            self.error = 'illegal number of tokens'
 
     @property
     def amount_short(self) -> str:
 
     @property
     def amount_short(self) -> str:
@@ -122,6 +154,30 @@ class Booking:
                                                    self.dat_lines[0].code)
         for dat_line in self.dat_lines[1:]:
             dat_line.booking_line = TransferLine(self.id_, dat_line.code)
                                                    self.dat_lines[0].code)
         for dat_line in self.dat_lines[1:]:
             dat_line.booking_line = TransferLine(self.id_, dat_line.code)
+        self.account_changes: dict[str, Wealth] = {}
+        self.sink_account = None
+        for dat_line in [dl for dl in self.dat_lines if dl.error]:
+            return
+        changes = Wealth()
+        for transfer_line in [dl.booking_line for dl in self.dat_lines[1:]]:
+            assert isinstance(transfer_line, TransferLine)
+            if transfer_line.account not in self.account_changes:
+                self.account_changes[transfer_line.account] = Wealth()
+            if transfer_line.amount is None:
+                if self.sink_account:
+                    transfer_line.error = 'second sink'
+                    return
+                self.sink_account = transfer_line.account
+                continue
+            change = Wealth({transfer_line.currency: transfer_line.amount})
+            self.account_changes[transfer_line.account] += change
+            changes += change
+        if self.sink_account:
+            self.account_changes[self.sink_account] += changes.as_sink
+        elif not changes.sink_empty:
+            last_line = self.dat_lines[-1]
+            assert isinstance(last_line.booking_line, BookingLine)
+            last_line.booking_line.error = 'needed sink missing'
 
 
 class Handler(PlomHttpHandler):
 
 
 class Handler(PlomHttpHandler):