owenrusk.dev

errands

small jobs the runtime does for the studio.

git clone https://owenrusk.dev/errands.git

commit 35a8fcecb23a553e4b270ef7399006aa8c71cc50
parent c2491a364fe2c71f63a54397c9fd5cc3a7ff280d
author Owen Rusk <owen@papermothgames.com>
date   2025-03-04 15:15:39 -0600
invoices: a ledger in state, no duplicates
errands/invoices.py+41-2
tests/test_invoices.py+20-1
diff --git a/errands/invoices.py b/errands/invoices.py
index 626cd52..e6d6220 100644
--- a/errands/invoices.py
+++ b/errands/invoices.py
@@ -1,7 +1,10 @@
-# bills that come in to the support inbox.
+# bills that come in to the support inbox, kept in a ledger.
+import json
 import re
-from dataclasses import dataclass
+from dataclasses import asdict, dataclass
+from pathlib import Path
 
+from . import mail, settings
 from .mail import Message
 
 PAYEES = {
@@ -25,3 +28,39 @@ def read(message: Message) -> Invoice | None:
     if payee is None or found is None:
         return None
     return Invoice(payee, message.sender, message.subject, found.group(1).replace(",", ""))
+
+
+def ledger() -> Path:
+    return settings.STATE / "invoices.json"
+
+
+def load() -> list[Invoice]:
+    path = ledger()
+    if not path.exists():
+        return []
+    return [Invoice(**row) for row in json.loads(path.read_text())]
+
+
+def save(invoices: list[Invoice]) -> None:
+    path = ledger()
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps([asdict(invoice) for invoice in invoices], indent=2) + "\n")
+
+
+def record(invoice: Invoice) -> bool:
+    invoices = load()
+    key = (invoice.sender, invoice.subject, invoice.amount)
+    if any((i.sender, i.subject, i.amount) == key for i in invoices):
+        return False
+    invoices.append(invoice)
+    save(invoices)
+    return True
+
+
+def from_inbox(unread=mail.unread) -> int:
+    added = 0
+    for message in unread():
+        invoice = read(message)
+        if invoice is not None and record(invoice):
+            added += 1
+    return added
diff --git a/tests/test_invoices.py b/tests/test_invoices.py
index f6ada3c..b6de9bd 100644
--- a/tests/test_invoices.py
+++ b/tests/test_invoices.py
@@ -1,6 +1,8 @@
+import tempfile
 import unittest
+from pathlib import Path
 
-from errands import invoices
+from errands import invoices, settings
 from errands.mail import Message
 
 
@@ -19,3 +21,20 @@ class ReadTest(unittest.TestCase):
 
     def test_no_amount(self):
         self.assertIsNone(invoices.read(message(body="the heating is on from monday.\n")))
+
+
+class LedgerTest(unittest.TestCase):
+    def setUp(self):
+        self.dir = tempfile.TemporaryDirectory()
+        self.state = settings.STATE
+        settings.STATE = Path(self.dir.name)
+
+    def tearDown(self):
+        settings.STATE = self.state
+        self.dir.cleanup()
+
+    def test_from_inbox_records_bills_once(self):
+        inbox = [message(), message(sender="someone@example.com")]
+        self.assertEqual(invoices.from_inbox(lambda: inbox), 1)
+        self.assertEqual(invoices.from_inbox(lambda: inbox), 0)
+        self.assertEqual([i.payee for i in invoices.load()], ["Calder & Webb"])