owenrusk.dev

errands

small jobs the runtime does for the studio.

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

commit b3cbde9abe67bb812b592b5545a151e00e54ec9a
parent cc62468c1935233814fd483197ce09f6c99c2904
author Owen Rusk <owen@papermothgames.com>
date   2025-07-29 14:00:17 -0500
invoices: read the due date
errands/invoices.py+15-3
tests/test_invoices.py+11-0
diff --git a/errands/invoices.py b/errands/invoices.py
index 14c1af0..1422e90 100644
--- a/errands/invoices.py
+++ b/errands/invoices.py
@@ -5,6 +5,7 @@ import json
 import re
 import urllib.request
 from dataclasses import asdict, dataclass
+from datetime import date, datetime, timedelta
 from pathlib import Path
 
 from . import mail, settings, vault
@@ -14,6 +15,7 @@ PAYEES = {
     "leasing@calderwebb.com": "Calder & Webb",
 }
 AMOUNT = re.compile(r"(?:amount|total|balance|rent)(?: due)?:?\s*\$([\d,]+\.\d{2})", re.IGNORECASE)
+DUE = re.compile(r"due(?: by| on)?:?\s+(?:[A-Z][a-z]+day,\s+)?([A-Z][a-z]+ \d{1,2}, \d{4})", re.IGNORECASE)
 
 
 @dataclass
@@ -22,6 +24,7 @@ class Invoice:
     sender: str
     subject: str
     amount: str
+    due: str | None = None
     paid: bool = False
 
 
@@ -30,7 +33,14 @@ def read(message: Message) -> Invoice | None:
     found = AMOUNT.search(message.body)
     if payee is None or found is None:
         return None
-    return Invoice(payee, message.sender, message.subject, found.group(1).replace(",", ""))
+    due = DUE.search(message.body)
+    return Invoice(
+        payee,
+        message.sender,
+        message.subject,
+        found.group(1).replace(",", ""),
+        datetime.strptime(due.group(1), "%B %d, %Y").date().isoformat() if due else None,
+    )
 
 
 def ledger() -> Path:
@@ -83,10 +93,12 @@ def pay(index: int, send=bill_pay) -> None:
     save(invoices)
 
 
-def pay_due(send=bill_pay) -> list[int]:
+def pay_due(send=bill_pay, today: date | None = None) -> list[int]:
+    # three days ahead, so it lands in time.
+    horizon = (today or date.today()) + timedelta(days=3)
     paid = []
     for index, invoice in enumerate(load()):
-        if not invoice.paid:
+        if not invoice.paid and (invoice.due is None or date.fromisoformat(invoice.due) <= horizon):
             pay(index, send)
             paid.append(index)
     return paid
diff --git a/tests/test_invoices.py b/tests/test_invoices.py
index dcc0f0a..7378aa6 100644
--- a/tests/test_invoices.py
+++ b/tests/test_invoices.py
@@ -1,5 +1,6 @@
 import tempfile
 import unittest
+from datetime import date
 from pathlib import Path
 
 from errands import invoices, settings
@@ -22,6 +23,10 @@ class ReadTest(unittest.TestCase):
     def test_no_amount(self):
         self.assertIsNone(invoices.read(message(body="the heating is on from monday.\n")))
 
+    def test_due_date(self):
+        invoice = invoices.read(message(body="Rent due: $1,875.00\nDue by: Friday, August 1, 2025\n"))
+        self.assertEqual(invoice.due, "2025-08-01")
+
 
 class LedgerTest(unittest.TestCase):
     def setUp(self):
@@ -46,3 +51,9 @@ class LedgerTest(unittest.TestCase):
         self.assertEqual(invoices.pay_due(sent.append), [])
         self.assertEqual(len(sent), 1)
         self.assertTrue(invoices.load()[0].paid)
+
+    def test_waits_until_close_to_due(self):
+        invoices.record(invoices.read(message(body="Rent due: $1,875.00\nDue by: August 1, 2025\n")))
+        sent = []
+        self.assertEqual(invoices.pay_due(sent.append, today=date(2025, 7, 20)), [])
+        self.assertEqual(invoices.pay_due(sent.append, today=date(2025, 7, 29)), [0])